diff --git a/app/src/dev/preview.ts b/app/src/dev/preview.ts
index e2ddc504..8815c275 100644
--- a/app/src/dev/preview.ts
+++ b/app/src/dev/preview.ts
@@ -17,6 +17,7 @@ import { seedChief } from "./seedChief";
import { seedContext } from "./seedContext";
import { seedDesktop } from "./seedDesktop";
import { seedHands } from "./seedHands";
+import { seedKeys } from "./seedKeys";
import { activeTab, drawPage } from "./seedPage";
import { seedPlan } from "./seedPlan";
import { seedQuestions } from "./seedQuestions";
@@ -128,6 +129,7 @@ function seed(fake: FakeBotloft): void {
});
seedRoutines(fake, { scout: scout.id, analyst: analyst.id });
seedQuestions(fake, { scout: scout.id, analyst: analyst.id });
+ seedKeys(fake, analyst.id, scout.id);
seedPlan(fake, writer.id);
seedBrowser(fake, scout.id);
// Analyst hit the limit in the middle of a search: its browser rests.
diff --git a/app/src/dev/seedKeys.ts b/app/src/dev/seedKeys.ts
new file mode 100644
index 00000000..0c972dc8
--- /dev/null
+++ b/app/src/dev/seedKeys.ts
@@ -0,0 +1,49 @@
+// Dev only: an agent's keys (spec 32), one kept and one that came from a
+// backup without its value, the agent asking for another, and keys pasted
+// in the conversations.
+
+import type { FakeBotloft } from "../lib/fake";
+import type { BotId } from "../lib/protocol.gen";
+
+export function seedKeys(fake: FakeBotloft, analyst: BotId, scout: BotId): void {
+ const now = Date.now();
+ fake.now = now - 3 * 24 * 60 * 60 * 1000;
+ void fake.call("keys.set", {
+ botId: analyst,
+ name: "SHEETS_API_KEY",
+ value: "dev-sheets-0000000000",
+ description: "Google Sheets, read only",
+ });
+ void fake.call("keys.set", { botId: analyst, name: "STRIPE_KEY", value: "dev-stripe-000000" });
+ const restored = fake.keys.saved.get(analyst)?.get("STRIPE_KEY");
+ if (restored) {
+ restored.missing = true;
+ }
+ fake.now = now - 5 * 60 * 1000;
+ fake.chat.tool(analyst, "mcp__botloft__ask_key", { status: "done" });
+ fake.keys.ask(
+ analyst,
+ "GITHUB_TOKEN",
+ "To open the pull request with the new price table. A token with access to the bakery-site repository is enough: GitHub, Settings, Developer settings, Fine-grained tokens.",
+ );
+ fake.now = now;
+ // Settings, Security: two keys pasted before they were kept apart.
+ fake.keys.pasted.push(
+ {
+ id: "dev_github",
+ value: "ghp_dev0000000000000000000000",
+ preview: "ghp_…0000",
+ botIds: [analyst, scout],
+ places: 3,
+ lastAt: now - 2 * 24 * 60 * 60 * 1000,
+ },
+ {
+ id: "dev_openai",
+ value: "sk-dev00000000000000000000",
+ preview: "sk-…0000",
+ botIds: [scout],
+ places: 1,
+ lastAt: now - 9 * 24 * 60 * 60 * 1000,
+ },
+ );
+}
diff --git a/app/src/features/account/SettingsPage.tsx b/app/src/features/account/SettingsPage.tsx
index e733220c..87e8f70e 100644
--- a/app/src/features/account/SettingsPage.tsx
+++ b/app/src/features/account/SettingsPage.tsx
@@ -12,12 +12,14 @@ import {
Palette,
Plug,
Search,
+ ShieldCheck,
SlidersHorizontal,
Smartphone,
} from "lucide-react";
import { useId, useMemo, useState } from "react";
import { useT } from "../../i18n";
import { ConnectedToolsSettings } from "../connections/ConnectedToolsSettings";
+import { PastedKeys } from "../keys/PastedKeys";
import { AboutSettings } from "./AboutSettings";
import { AccountSettings } from "./AccountSettings";
import { AlertsSettings } from "./AlertsSettings";
@@ -51,6 +53,7 @@ const GROUPS: { id: GroupId; pages: { id: Page; icon: LucideIcon }[] }[] = [
id: "groupData",
pages: [
{ id: "backup", icon: DatabaseBackup },
+ { id: "security", icon: ShieldCheck },
{ id: "archived", icon: Archive },
],
},
@@ -71,6 +74,7 @@ export function SettingsPage() {
account: s.account,
tools: s.tools,
backup: s.backup,
+ security: s.security,
archived: s.archived,
about: s.about,
};
@@ -163,6 +167,7 @@ export function SettingsPage() {
{page === "account" && }
{page === "tools" && }
{page === "backup" && open("account")} />}
+ {page === "security" && }
{page === "archived" && }
{page === "about" && }
>
diff --git a/app/src/features/account/settings.test.tsx b/app/src/features/account/settings.test.tsx
index 91cd8ca8..0f312d9a 100644
--- a/app/src/features/account/settings.test.tsx
+++ b/app/src/features/account/settings.test.tsx
@@ -249,6 +249,7 @@ describe("the page of settings", () => {
"Account and phone",
"Connected tools",
"Backup",
+ "Security",
"Archived",
"About",
]);
diff --git a/app/src/features/account/settingsIndex.ts b/app/src/features/account/settingsIndex.ts
index e64b9194..93ab1c63 100644
--- a/app/src/features/account/settingsIndex.ts
+++ b/app/src/features/account/settingsIndex.ts
@@ -12,6 +12,7 @@ export type Page =
| "account"
| "tools"
| "backup"
+ | "security"
| "archived"
| "about";
@@ -46,6 +47,7 @@ export function settingsIndex(t: ReturnType): Entry[] {
...entry("account", t.account.cloud.title, t.account.mobile.title, t.account.mobile.connect),
...entry("tools", s.tools),
...entry("backup", t.account.backup.exportTitle, t.account.backup.importTitle, s.copiesTitle),
+ ...entry("security", t.keys.scan.title, t.keys.scan.run),
...entry("archived", s.archived),
...entry("about", s.about, s.checkUpdates),
];
diff --git a/app/src/features/bots/BotPanels.tsx b/app/src/features/bots/BotPanels.tsx
index bfbd825e..59871e7b 100644
--- a/app/src/features/bots/BotPanels.tsx
+++ b/app/src/features/bots/BotPanels.tsx
@@ -11,6 +11,7 @@ import { Callout } from "../../ui/Callout";
import { SidePanel } from "../../ui/SidePanel";
import { BotTools } from "../connections/BotTools";
import { DesktopGrants } from "../desktop/DesktopGrants";
+import { AgentKeys } from "../keys/AgentKeys";
import { SignInButton } from "../onboarding/SignIn";
import { AllowRules } from "./AllowRules";
import type { stateView } from "./BotStateBadge";
@@ -95,6 +96,7 @@ export function Details({ bot, onClose }: { bot: Bot; onClose(): void }) {
+
diff --git a/app/src/features/chat/BotRun.tsx b/app/src/features/chat/BotRun.tsx
index ff86f15c..0c1081cf 100644
--- a/app/src/features/chat/BotRun.tsx
+++ b/app/src/features/chat/BotRun.tsx
@@ -12,6 +12,7 @@ import { useArrival } from "../../ui/motion";
import { BotAvatar, moodOf } from "../bots/BotAvatar";
import { QuestionCard } from "../questions/QuestionCard";
import { ApprovalCard } from "./ApprovalCard";
+import { KeyRequestCard } from "./KeyRequestCard";
import { Markdown } from "./Markdown";
import { MemoryNote } from "./MemoryNote";
import { memoryOf, memoryPlaces } from "./memory";
@@ -182,6 +183,12 @@ function BotRunView({
);
+ case "key_request":
+ return (
+
+
+
+ );
case "turn":
return (
diff --git a/app/src/features/chat/ChatComposer.tsx b/app/src/features/chat/ChatComposer.tsx
index 9e411f11..b46dc456 100644
--- a/app/src/features/chat/ChatComposer.tsx
+++ b/app/src/features/chat/ChatComposer.tsx
@@ -22,6 +22,7 @@ import { type Bot, FIELD_LIMITS } from "../../lib/protocol.gen";
import { prefs, usePref } from "../../shell/prefs";
import { playSound } from "../../shell/sounds";
import { useApi } from "../../store/context";
+import { pastedKeys } from "../keys/pasted";
import { AgentModelPicker } from "./AgentModelPicker";
import { ContextMeter } from "./ContextMeter";
import { draftOf, keepDraft } from "./drafts";
@@ -85,7 +86,10 @@ export const ChatComposer = memo(function ChatComposer({
const setText = (value: string) => {
setShown(value);
keepDraft(botId, value);
+ setKept(null);
};
+ /** Saved keys the owner pasted in the last message, sent by name (spec 32.6). */
+ const [kept, setKept] = useState(null);
const [busy, setBusy] = useState(false);
/** A new mode, model or effort, or a compaction, that waits for the bot to finish what it is doing. */
const [later, setLater] = useState(null);
@@ -144,6 +148,8 @@ export const ChatComposer = memo(function ChatComposer({
}
});
setText("");
+ const pasted = pastedKeys(text, message.body);
+ setKept(pasted.length > 0 ? pasted : null);
files.clear();
onCancelReply?.();
playSound("sent");
@@ -186,6 +192,11 @@ export const ChatComposer = memo(function ChatComposer({
{stopped && (
{t.chat.composer.paused(bot.name)}
)}
+ {kept && (
+
+ {t.keys.pasted(kept, bot.name)}
+
+ )}
{later && (
{later}
diff --git a/app/src/features/chat/KeyRequestCard.tsx b/app/src/features/chat/KeyRequestCard.tsx
new file mode 100644
index 00000000..be8e2218
--- /dev/null
+++ b/app/src/features/chat/KeyRequestCard.tsx
@@ -0,0 +1,79 @@
+// An agent asking the owner for a key (spec 32.5): the name, what it is for
+// and the form that keeps it out of the chat. Once answered it shrinks to
+// one line that names the key, never its value.
+
+import { Check, KeyRound, X } from "lucide-react";
+import { useState } from "react";
+import { useT } from "../../i18n";
+import type { Bot, ChatItemId, KeyRequestItem } from "../../lib/protocol.gen";
+import { useApi } from "../../store/context";
+import { Button } from "../../ui/Button";
+import { SectionCard, SettledLine } from "../../ui/ChatCard";
+import { attempt } from "../../ui/toast";
+import { KeyForm } from "../keys/KeyForm";
+
+export function KeyRequestCard({
+ id,
+ request,
+ bot,
+}: {
+ id: ChatItemId;
+ request: KeyRequestItem;
+ bot: Bot;
+}) {
+ const t = useT().keys.card;
+ if (request.status === "saved") {
+ return ;
+ }
+ if (request.status === "dismissed") {
+ return ;
+ }
+ return ;
+}
+
+function OpenRequest({ id, request, bot }: { id: ChatItemId; request: KeyRequestItem; bot: Bot }) {
+ const t = useT().keys.card;
+ const api = useApi();
+ const [busy, setBusy] = useState(false);
+ const notNow = async () => {
+ setBusy(true);
+ await attempt(t.dismissFailed, () => api.call("keys.dismiss", { requestId: id }));
+ setBusy(false);
+ };
+ return (
+ {}}
+ actions={
+
+ }
+ />
+ }
+ >
+
+
{request.name}
+
+ {t.why}:
+ {request.why}
+
+
+
+ );
+}
diff --git a/app/src/features/chat/rows.ts b/app/src/features/chat/rows.ts
index 322088ea..0721bd71 100644
--- a/app/src/features/chat/rows.ts
+++ b/app/src/features/chat/rows.ts
@@ -14,10 +14,14 @@ export type Row =
| { kind: "notice"; key: string; item: ChatItem; notice: NoticeItem };
/**
- * Tools whose card says it all: the bot asking the owner (spec 23.2) and
- * the plan it shows before long work (spec 10.5).
+ * Tools whose card says it all: the bot asking the owner (spec 23.2) or
+ * for a key (spec 32.5), and the plan it shows before long work (spec 10.5).
*/
-const CARD_TOOLS = new Set(["mcp__botloft__ask_owner", "mcp__botloft__show_plan"]);
+const CARD_TOOLS = new Set([
+ "mcp__botloft__ask_owner",
+ "mcp__botloft__ask_key",
+ "mcp__botloft__show_plan",
+]);
/** A call that worked and has its own card, which stands for it. */
function isAskLine(item: ChatItem): boolean {
diff --git a/app/src/features/keys/AgentKeys.tsx b/app/src/features/keys/AgentKeys.tsx
new file mode 100644
index 00000000..75a894ee
--- /dev/null
+++ b/app/src/features/keys/AgentKeys.tsx
@@ -0,0 +1,162 @@
+// The Keys part of an agent's details (spec 32.5): each key by name, with
+// what it is for, when it changed, and whether its value is on this
+// computer; replace, delete, and add one. A value never comes back here.
+
+import { KeyRound, Plus, RefreshCw, Trash2 } from "lucide-react";
+import { useEffect, useState } from "react";
+import { useT } from "../../i18n";
+import { when } from "../../lib/format";
+import type { AgentKey, Bot } from "../../lib/protocol.gen";
+import { useApi } from "../../store/context";
+import { Badge } from "../../ui/Badge";
+import { Button } from "../../ui/Button";
+import { Confirm } from "../../ui/Confirm";
+import { notifyError } from "../../ui/toast";
+import { KeyForm } from "./KeyForm";
+
+/** The agent's keys, read when shown and again when it gets one it asked for. */
+function useKeys(botId: Bot["id"]) {
+ const api = useApi();
+ const [keys, setKeys] = useState(null);
+ useEffect(() => {
+ let alive = true;
+ const read = () =>
+ api.call("keys.list", { botId }).then(
+ (listed) => alive && setKeys(listed),
+ // An older daemon has no keys: the part stays empty.
+ () => alive && setKeys([]),
+ );
+ setKeys(null);
+ read();
+ const stop = api.subscribe((event) => {
+ if (
+ event.name === "chat.item" &&
+ event.params.item.botId === botId &&
+ event.params.item.body.kind === "key_request" &&
+ event.params.item.body.status === "saved"
+ ) {
+ read();
+ }
+ });
+ return () => {
+ alive = false;
+ stop();
+ };
+ }, [api, botId]);
+ return [keys, setKeys] as const;
+}
+
+/** Which form is open: a new key, or replacing one by name. */
+type Editing = { kind: "add" } | { kind: "replace"; name: string } | null;
+
+export function AgentKeys({ bot }: { bot: Bot }) {
+ const t = useT().keys;
+ const words = t.details;
+ const api = useApi();
+ const [keys, setKeys] = useKeys(bot.id);
+ const [editing, setEditing] = useState(null);
+ const [removing, setRemoving] = useState(null);
+
+ const saved = (listed: AgentKey[]) => {
+ setKeys(listed);
+ setEditing(null);
+ };
+ const cancel = (
+
+ );
+
+ return (
+
+ );
+}
+
+function KeepForm({
+ hit,
+ onCancel,
+ onKeep,
+}: {
+ hit: KeyHit;
+ onCancel(): void;
+ onKeep(botId: BotId, name: string): Promise;
+}) {
+ const words = useT().keys;
+ const bots = useApp((state) => state.bots);
+ // Only agents still here can keep a key.
+ const choices = hit.botIds.filter((id) => bots[id]);
+ const all = Object.values(bots).filter((bot) => !choices.includes(bot.id));
+ const options = [...choices.map((id) => bots[id]), ...all].flatMap((bot) =>
+ bot ? [{ value: bot.id, label: bot.name }] : [],
+ );
+ const [botId, setBotId] = useState(options[0]?.value ?? "");
+ const [name, setName] = useState("");
+ const [busy, setBusy] = useState(false);
+
+ const keep = async (event: FormEvent) => {
+ event.preventDefault();
+ setBusy(true);
+ await onKeep(botId as BotId, name);
+ setBusy(false);
+ };
+
+ return (
+
+ );
+}
diff --git a/app/src/features/keys/keys.test.tsx b/app/src/features/keys/keys.test.tsx
new file mode 100644
index 00000000..10ba44c7
--- /dev/null
+++ b/app/src/features/keys/keys.test.tsx
@@ -0,0 +1,135 @@
+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 { pastedKeys } from "./pasted";
+
+afterEach(cleanup);
+
+const TOKEN = "ghp-live-1234567890";
+
+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 saved = (fake: FakeBotloft) =>
+ fake.calls.find((call) => call.method === "keys.set")?.params as
+ | { name: string; value: string; requestId?: string }
+ | undefined;
+
+describe("an agent asking for a key", () => {
+ test("the owner types it in a hidden field and the card shrinks to its name", async () => {
+ const { fake, scout } = await openScout();
+ let id = "";
+ act(() => {
+ id = fake.keys.ask(scout.id, "GITHUB_TOKEN", "To open the pull request").id;
+ });
+ const card = screen.getByRole("region", { name: "Scout needs a key" });
+ expect(within(card).getByText("To open the pull request")).toBeDefined();
+ const field = within(card).getByLabelText("Key GITHUB_TOKEN");
+ expect(field.getAttribute("type")).toBe("password");
+ fireEvent.click(within(card).getByRole("button", { name: "Show the key" }));
+ expect(field.getAttribute("type")).toBe("text");
+ fireEvent.change(field, { target: { value: TOKEN } });
+ fireEvent.click(within(card).getByRole("button", { name: "Save" }));
+ expect(await screen.findByText("You saved the key GITHUB_TOKEN")).toBeDefined();
+ expect(saved(fake)).toMatchObject({ name: "GITHUB_TOKEN", value: TOKEN, requestId: id });
+ expect(screen.queryByText(TOKEN)).toBeNull();
+ });
+
+ test("not now closes the request without a key", async () => {
+ const { fake, scout } = await openScout();
+ act(() => {
+ fake.keys.ask(scout.id, "STRIPE_KEY", "To read invoices");
+ });
+ const card = screen.getByRole("region", { name: "Scout needs a key" });
+ fireEvent.click(within(card).getByRole("button", { name: "Not now" }));
+ expect(await screen.findByText("You did not give the key STRIPE_KEY")).toBeDefined();
+ expect(fake.keys.saved.size).toBe(0);
+ });
+});
+
+describe("the Keys part of an agent's details", () => {
+ test("adds, marks a missing value, replaces and deletes a key", async () => {
+ const { fake, scout } = await openScout();
+ fireEvent.click(screen.getByRole("button", { name: /^Show details/ }));
+ const details = screen.getByRole("complementary", { name: "About Scout" });
+ expect(await within(details).findByText(/^No keys yet/)).toBeDefined();
+
+ fireEvent.click(within(details).getByRole("button", { name: "Add a key" }));
+ fireEvent.change(within(details).getByLabelText("Name"), {
+ target: { value: "wa token" },
+ });
+ expect((within(details).getByLabelText("Name") as HTMLInputElement).value).toBe("WA_TOKEN");
+ fireEvent.change(within(details).getByLabelText("Key"), { target: { value: TOKEN } });
+ fireEvent.change(within(details).getByLabelText("Description (optional)"), {
+ target: { value: "WhatsApp gateway" },
+ });
+ fireEvent.click(within(details).getByRole("button", { name: "Save" }));
+ const list = await within(details).findByRole("list", { name: "Keys" });
+ expect(within(list).getByText("WA_TOKEN")).toBeDefined();
+ expect(within(list).getByText(/^WhatsApp gateway · changed/)).toBeDefined();
+ expect(within(list).queryByText("Value missing")).toBeNull();
+
+ const kept = fake.keys.saved.get(scout.id)?.get("WA_TOKEN");
+ if (kept) {
+ kept.missing = true;
+ }
+ fireEvent.click(within(list).getByRole("button", { name: "Replace WA_TOKEN" }));
+ fireEvent.change(within(details).getByLabelText("Key WA_TOKEN"), {
+ target: { value: "ghp-live-0987654321" },
+ });
+ fireEvent.click(within(details).getByRole("button", { name: "Save" }));
+ await waitFor(() =>
+ expect(fake.keys.saved.get(scout.id)?.get("WA_TOKEN")?.value).toBe("ghp-live-0987654321"),
+ );
+
+ fireEvent.click(within(details).getByRole("button", { name: "Delete WA_TOKEN" }));
+ const confirm = screen.getByRole("dialog", { name: "Delete the key WA_TOKEN?" });
+ fireEvent.click(within(confirm).getByRole("button", { name: "Delete" }));
+ expect(await within(details).findByText(/^No keys yet/)).toBeDefined();
+ });
+
+ test("a key without its value on this computer says so", async () => {
+ const { fake, scout } = await openScout();
+ await fake.call("keys.set", { botId: scout.id, name: "WA_TOKEN", value: TOKEN });
+ const kept = fake.keys.saved.get(scout.id)?.get("WA_TOKEN");
+ if (kept) {
+ kept.missing = true;
+ }
+ fireEvent.click(screen.getByRole("button", { name: /^Show details/ }));
+ const details = screen.getByRole("complementary", { name: "About Scout" });
+ expect(await within(details).findByText("Value missing")).toBeDefined();
+ });
+});
+
+describe("a saved key pasted in the chat", () => {
+ test("the agent gets the name and the owner is told", async () => {
+ const { fake, scout } = await openScout();
+ await fake.call("keys.set", { botId: scout.id, name: "WA_TOKEN", value: TOKEN });
+ fireEvent.change(screen.getByRole("textbox", { name: /^Message to Scout/ }), {
+ target: { value: `use ${TOKEN} please` },
+ });
+ fireEvent.click(screen.getByRole("button", { name: /^Send/ }));
+ expect(
+ await screen.findByText(
+ "The key WA_TOKEN is already kept, so Scout got the name in place of the value.",
+ ),
+ ).toBeDefined();
+ expect(screen.queryByText(new RegExp(TOKEN))).toBeNull();
+ });
+
+ test("only names the daemon swapped count, each once", () => {
+ expect(pastedKeys("hi", "hi")).toEqual([]);
+ expect(pastedKeys("a b a", "[chave A_KEY] b [chave A_KEY]")).toEqual(["A_KEY"]);
+ expect(pastedKeys("[chave OLD] x", "[chave OLD] [chave NEW]")).toEqual(["NEW"]);
+ });
+});
diff --git a/app/src/features/keys/pasted.ts b/app/src/features/keys/pasted.ts
new file mode 100644
index 00000000..eb1d8a07
--- /dev/null
+++ b/app/src/features/keys/pasted.ts
@@ -0,0 +1,15 @@
+// Finding the keys the daemon swapped for their names in what the owner
+// sent (spec 32.6): `[chave NAME]` in the message that went out and not in
+// what was typed.
+
+const MARK = /\[chave ([A-Z][A-Z0-9_]*)\]/g;
+
+function names(text: string): Set {
+ return new Set([...text.matchAll(MARK)].map((match) => match[1] ?? ""));
+}
+
+/** The keys swapped in `sent`, in order, each once. */
+export function pastedKeys(typed: string, sent: string): string[] {
+ const before = names(typed);
+ return [...names(sent)].filter((name) => name && !before.has(name));
+}
diff --git a/app/src/features/keys/pastedKeys.test.tsx b/app/src/features/keys/pastedKeys.test.tsx
new file mode 100644
index 00000000..1a97345b
--- /dev/null
+++ b/app/src/features/keys/pastedKeys.test.tsx
@@ -0,0 +1,87 @@
+import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
+import { afterEach, describe, expect, test } from "vitest";
+import { FakeBotloft } from "../../lib/fake";
+import { crewOpened, renderApp } from "../../test/app";
+
+afterEach(cleanup);
+
+const GITHUB = "ghp_abcdefghijklmnopqrstuvwxyz0123";
+// In two parts, so the push protection of a code host does not take it for a
+// real token.
+const SLACK = ["xox", "b-1234567890-abcdefghijklmnop"].join("");
+
+async function openSecurity() {
+ const fake = new FakeBotloft();
+ const ops = fake.addCrew("Ops");
+ const scout = fake.addBot(ops.id, "Scout");
+ const writer = fake.addBot(ops.id, "Writer");
+ fake.keys.pasted.push(
+ {
+ id: "hit_github",
+ value: GITHUB,
+ preview: "ghp_…0123",
+ botIds: [writer.id, scout.id],
+ places: 3,
+ lastAt: fake.now,
+ },
+ {
+ id: "hit_slack",
+ value: SLACK,
+ preview: "xoxb-…mnop",
+ botIds: [scout.id],
+ places: 1,
+ lastAt: fake.now,
+ },
+ );
+ renderApp(fake);
+ await crewOpened("Ops");
+ fireEvent.click(await screen.findByRole("button", { name: /Ana Lima/ }));
+ fireEvent.click(screen.getByRole("menuitem", { name: "Settings" }));
+ const page = screen.getByRole("region", { name: "Settings" });
+ fireEvent.click(within(page).getByRole("tab", { name: "Security" }));
+ return { fake, page, scout, writer };
+}
+
+describe("keys pasted in the conversations", () => {
+ test("shows only how each starts and ends, and keeps one for an agent", async () => {
+ const { fake, page, writer } = await openSecurity();
+ fireEvent.click(within(page).getByRole("button", { name: "Look for keys" }));
+ const list = await within(page).findByRole("list", { name: "2 keys found" });
+ expect(within(list).getByText("ghp_…0123")).toBeDefined();
+ expect(within(list).getByText(/^Writer, Scout · in 3 places/)).toBeDefined();
+ expect(page.textContent).not.toContain(GITHUB);
+ expect(within(page).getByText("Change the key at the service it came from")).toBeDefined();
+
+ const row = within(list).getByText("ghp_…0123").closest("li") as HTMLElement;
+ fireEvent.click(within(row).getByRole("button", { name: "Keep as a key" }));
+ expect((within(row).getByLabelText("Agent") as HTMLSelectElement).value).toBe(writer.id);
+ fireEvent.change(within(row).getByLabelText("Name"), { target: { value: "github token" } });
+ fireEvent.click(within(row).getByRole("button", { name: "Save" }));
+ await waitFor(() =>
+ expect(fake.keys.saved.get(writer.id)?.get("GITHUB_TOKEN")?.value).toBe(GITHUB),
+ );
+ expect(await within(page).findByRole("list", { name: "1 key found" })).toBeDefined();
+ });
+
+ test("deletes one from Botloft after asking", async () => {
+ const { fake, page } = await openSecurity();
+ fireEvent.click(within(page).getByRole("button", { name: "Look for keys" }));
+ const list = await within(page).findByRole("list", { name: "2 keys found" });
+ const row = within(list).getByText("xoxb-…mnop").closest("li") as HTMLElement;
+ fireEvent.click(within(row).getByRole("button", { name: "Delete from Botloft" }));
+ const confirm = screen.getByRole("dialog", { name: "Delete xoxb-…mnop from Botloft?" });
+ fireEvent.click(within(confirm).getByRole("button", { name: "Delete from Botloft" }));
+ expect(await within(page).findByRole("list", { name: "1 key found" })).toBeDefined();
+ expect(fake.keys.saved.size).toBe(0);
+ const scrubbed = fake.calls.find((call) => call.method === "keys.scrub")?.params;
+ expect(scrubbed).toEqual({ hits: [{ id: "hit_slack", keep: null }] });
+ });
+
+ test("says when there is nothing to change", async () => {
+ const { fake, page } = await openSecurity();
+ fake.keys.pasted.length = 0;
+ fireEvent.click(within(page).getByRole("button", { name: "Look for keys" }));
+ expect(await within(page).findByText("No keys found in the conversations.")).toBeDefined();
+ expect(within(page).getByRole("button", { name: "Look again" })).toBeDefined();
+ });
+});
diff --git a/app/src/i18n/en/account.ts b/app/src/i18n/en/account.ts
index a3abc7f2..1b8f58f3 100644
--- a/app/src/i18n/en/account.ts
+++ b/app/src/i18n/en/account.ts
@@ -292,6 +292,7 @@ export const account = {
sizeHint: "Ctrl+= and Ctrl+- change the size too, and Ctrl+0 goes back to the default.",
language: "Language",
archived: "Archived",
+ security: "Security",
archivedIntro:
"Agents and crews you archived are stopped and out of sight, and Botloft still keeps their conversations. Delete one to remove it for good.",
archivedEmpty: "Nothing is archived.",
diff --git a/app/src/i18n/en/index.ts b/app/src/i18n/en/index.ts
index d03b0d6d..4b195644 100644
--- a/app/src/i18n/en/index.ts
+++ b/app/src/i18n/en/index.ts
@@ -12,6 +12,7 @@ import { crews } from "./crews";
import { crewTemplates } from "./crewTemplates";
import { desktop } from "./desktop";
import { files } from "./files";
+import { keys } from "./keys";
import { messages } from "./messages";
import { onboarding } from "./onboarding";
import { phone } from "./phone";
@@ -41,6 +42,7 @@ export const en = {
crewTemplates,
desktop,
files,
+ keys,
messages,
phone,
questions,
diff --git a/app/src/i18n/en/keys.ts b/app/src/i18n/en/keys.ts
new file mode 100644
index 00000000..7dd97830
--- /dev/null
+++ b/app/src/i18n/en/keys.ts
@@ -0,0 +1,74 @@
+// An agent's keys (spec 32): the card where an agent asks for one, the Keys
+// part of its details and the word after the owner pastes a saved key.
+
+export const keys = {
+ card: {
+ asks: (bot: string) => `${bot} needs a key`,
+ why: "What for",
+ saved: (name: string) => `You saved the key ${name}`,
+ dismissed: (name: string) => `You did not give the key ${name}`,
+ notNow: "Not now",
+ notNowHint: (bot: string) => `Closes the request and tells ${bot} the key is not coming now.`,
+ dismissFailed: "Could not close the request",
+ },
+ form: {
+ name: "Name",
+ nameHint: "Capital letters, digits and _, like GITHUB_TOKEN. The agent reads the key by it.",
+ value: "Key",
+ valueHint: "Kept on this computer only, out of the chat. Nobody can read it back here.",
+ description: "Description (optional)",
+ descriptionPlaceholder: "What it is for",
+ show: "Show the key",
+ hide: "Hide the key",
+ save: "Save",
+ cancel: "Cancel",
+ saveFailed: "Could not save the key",
+ restarts: (bot: string) => `${bot} restarts to get it, without losing the conversation.`,
+ },
+ details: {
+ title: "Keys",
+ none: (bot: string) =>
+ `No keys yet. When ${bot} needs a token or a password for another service, keep it here instead of pasting it in the chat.`,
+ howItWorks: "How it works",
+ explain: (bot: string) =>
+ `${bot} gets each key as a variable by its name, and Botloft swaps a key it sees in the chat for its name. It is not a guarantee: if ${bot} prints a key anyway, it ends up in the engine's own history. The best protection is a key that can do little.`,
+ add: "Add a key",
+ replace: (name: string) => `Replace ${name}`,
+ remove: (name: string) => `Delete ${name}`,
+ missing: "Value missing",
+ missingHint:
+ "The name came with a backup, the value did not. Replace it to give the key again.",
+ changed: (when: string) => `changed ${when}`,
+ removeTitle: (name: string) => `Delete the key ${name}?`,
+ removeBody: (bot: string) =>
+ `${bot} restarts without it. The key still works at the service it came from: to stop it there, revoke it at that service.`,
+ removeConfirm: "Delete",
+ removeFailed: "Could not delete the key",
+ },
+ scan: {
+ title: "Keys pasted in the conversations",
+ intro:
+ "Botloft looks through what you and your agents wrote for text that looks like a key: tokens from GitHub, Slack, Stripe, OpenAI, Anthropic, Google, AWS and sign-in tokens. It shows only how each one starts and ends.",
+ run: "Look for keys",
+ again: "Look again",
+ none: "No keys found in the conversations.",
+ found: (count: number) => (count === 1 ? "1 key found" : `${count} keys found`),
+ places: (count: number) => (count === 1 ? "in 1 place" : `in ${count} places`),
+ keep: "Keep as a key",
+ keepTitle: "Keep as a key of",
+ agent: "Agent",
+ goneAgent: "an agent that is gone",
+ remove: "Delete from Botloft",
+ removeTitle: (preview: string) => `Delete ${preview} from Botloft?`,
+ removeBody:
+ "Every place it shows up will read [chave removida]. This cannot be undone, and the key still works at the service it came from.",
+ done: "Done",
+ failed: "Could not look for keys",
+ scrubFailed: "Could not change the conversations",
+ reachTitle: "Change the key at the service it came from",
+ reach:
+ "It already went through places Botloft does not control. Botloft does not touch the engine's own conversation history (starting a new conversation with the agent leaves it behind) or cloud copies already made (the next one goes without the key; old ones are deleted in Account).",
+ },
+ pasted: (names: string[], bot: string) =>
+ `${names.length === 1 ? `The key ${names[0]} is` : `The keys ${names.join(", ")} are`} already kept, so ${bot} got the name in place of the value.`,
+};
diff --git a/app/src/i18n/en/tools.ts b/app/src/i18n/en/tools.ts
index c0fee281..7b84dafe 100644
--- a/app/src/i18n/en/tools.ts
+++ b/app/src/i18n/en/tools.ts
@@ -40,6 +40,7 @@ export const tools = {
ask_crew_access: "Reach another crew",
share_file: "Share a file",
ask_owner: "Ask you a question",
+ ask_key: "Ask you for a key",
show_plan: "Show you the plan",
browser: "Use a site",
browser_help: "Ask for your hand",
diff --git a/app/src/i18n/es/account.ts b/app/src/i18n/es/account.ts
index 8c85e95e..055c0e89 100644
--- a/app/src/i18n/es/account.ts
+++ b/app/src/i18n/es/account.ts
@@ -299,6 +299,7 @@ export const account: Messages["account"] = {
sizeHint: "Ctrl+= y Ctrl+- también cambian el tamaño, y Ctrl+0 vuelve al predeterminado.",
language: "Idioma",
archived: "Archivados",
+ security: "Seguridad",
archivedIntro:
"Los agentes y los equipos que archivaste están detenidos y fuera de la vista, y Botloft todavía guarda sus conversaciones. Elimina uno para quitarlo para siempre.",
archivedEmpty: "No hay nada archivado.",
diff --git a/app/src/i18n/es/index.ts b/app/src/i18n/es/index.ts
index bbf030d6..71feb2de 100644
--- a/app/src/i18n/es/index.ts
+++ b/app/src/i18n/es/index.ts
@@ -13,6 +13,7 @@ import { crews } from "./crews";
import { crewTemplates } from "./crewTemplates";
import { desktop } from "./desktop";
import { files } from "./files";
+import { keys } from "./keys";
import { messages } from "./messages";
import { onboarding } from "./onboarding";
import { phone } from "./phone";
@@ -42,6 +43,7 @@ export const es: Messages = {
crewTemplates,
desktop,
files,
+ keys,
messages,
phone,
questions,
diff --git a/app/src/i18n/es/keys.ts b/app/src/i18n/es/keys.ts
new file mode 100644
index 00000000..0ae6e093
--- /dev/null
+++ b/app/src/i18n/es/keys.ts
@@ -0,0 +1,76 @@
+// Claves de los agentes (spec 32).
+
+import type { Messages } from "../en";
+
+export const keys: Messages["keys"] = {
+ card: {
+ asks: (bot: string) => `${bot} necesita una clave`,
+ why: "Para qué",
+ saved: (name: string) => `Guardaste la clave ${name}`,
+ dismissed: (name: string) => `No diste la clave ${name}`,
+ notNow: "Ahora no",
+ notNowHint: (bot: string) => `Cierra el pedido y avisa a ${bot} que la clave no llega ahora.`,
+ dismissFailed: "No se pudo cerrar el pedido",
+ },
+ form: {
+ name: "Nombre",
+ nameHint: "Mayúsculas, números y _, como GITHUB_TOKEN. El agente lee la clave por él.",
+ value: "Clave",
+ valueHint:
+ "Queda solo en esta computadora, fuera de la conversación. Nadie puede leerla de vuelta aquí.",
+ description: "Descripción (opcional)",
+ descriptionPlaceholder: "Para qué sirve",
+ show: "Mostrar la clave",
+ hide: "Ocultar la clave",
+ save: "Guardar",
+ cancel: "Cancelar",
+ saveFailed: "No se pudo guardar la clave",
+ restarts: (bot: string) => `${bot} se reinicia para recibirla, sin perder la conversación.`,
+ },
+ details: {
+ title: "Claves",
+ none: (bot: string) =>
+ `Ninguna clave todavía. Cuando ${bot} necesite un token o una contraseña de otro servicio, guárdala aquí en vez de pegarla en el chat.`,
+ howItWorks: "Cómo funciona",
+ explain: (bot: string) =>
+ `${bot} recibe cada clave como una variable con su nombre, y Botloft cambia por el nombre una clave que aparece en el chat. No es una garantía: si ${bot} imprime la clave de todos modos, queda en el historial del propio motor. La mejor protección es una clave que puede poco.`,
+ add: "Agregar clave",
+ replace: (name: string) => `Cambiar ${name}`,
+ remove: (name: string) => `Borrar ${name}`,
+ missing: "Falta el valor",
+ missingHint:
+ "El nombre vino con una copia de seguridad, el valor no. Cámbiala para dar la clave de nuevo.",
+ changed: (when: string) => `cambió ${when}`,
+ removeTitle: (name: string) => `¿Borrar la clave ${name}?`,
+ removeBody: (bot: string) =>
+ `${bot} se reinicia sin ella. La clave sigue valiendo en el servicio de donde vino: para anularla allí, revócala en ese servicio.`,
+ removeConfirm: "Borrar",
+ removeFailed: "No se pudo borrar la clave",
+ },
+ scan: {
+ title: "Claves pegadas en las conversaciones",
+ intro:
+ "Botloft busca en lo que tú y tus agentes escribieron textos con forma de clave: tokens de GitHub, Slack, Stripe, OpenAI, Anthropic, Google, AWS y tokens de inicio de sesión. Muestra solo el comienzo y el final de cada una.",
+ run: "Buscar claves",
+ again: "Buscar de nuevo",
+ none: "No se encontraron claves en las conversaciones.",
+ found: (count: number) => (count === 1 ? "1 clave encontrada" : `${count} claves encontradas`),
+ places: (count: number) => (count === 1 ? "en 1 lugar" : `en ${count} lugares`),
+ keep: "Guardar como clave",
+ keepTitle: "Guardar como clave de",
+ agent: "Agente",
+ goneAgent: "un agente que ya no existe",
+ remove: "Borrar de Botloft",
+ removeTitle: (preview: string) => `¿Borrar ${preview} de Botloft?`,
+ removeBody:
+ "Todos los lugares donde aparece quedarán [chave removida]. No se puede deshacer, y la clave sigue valiendo en el servicio de donde vino.",
+ done: "Listo",
+ failed: "No se pudo buscar claves",
+ scrubFailed: "No se pudieron cambiar las conversaciones",
+ reachTitle: "Cambia la clave en el servicio de donde vino",
+ reach:
+ "Ya pasó por lugares que Botloft no controla. Botloft no toca el historial de conversación del propio motor (empezar una conversación nueva con el agente lo deja atrás) ni las copias en la nube ya hechas (la próxima sale sin la clave; las antiguas se borran en Cuenta).",
+ },
+ pasted: (names: string[], bot: string) =>
+ `${names.length === 1 ? `La clave ${names[0]} ya está guardada` : `Las claves ${names.join(", ")} ya están guardadas`}, así que ${bot} recibió el nombre en lugar del valor.`,
+};
diff --git a/app/src/i18n/es/tools.ts b/app/src/i18n/es/tools.ts
index 81e5bf6f..85625160 100644
--- a/app/src/i18n/es/tools.ts
+++ b/app/src/i18n/es/tools.ts
@@ -41,6 +41,7 @@ export const tools: Messages["tools"] = {
ask_crew_access: "Acceder a otro equipo",
share_file: "Compartir un archivo",
ask_owner: "Hacerte una pregunta",
+ ask_key: "Pedirte una clave",
show_plan: "Mostrarte el plan",
browser: "Usar un sitio",
browser_help: "Pedir tu ayuda",
diff --git a/app/src/i18n/pt-BR/account.ts b/app/src/i18n/pt-BR/account.ts
index a62931c6..a41f1fab 100644
--- a/app/src/i18n/pt-BR/account.ts
+++ b/app/src/i18n/pt-BR/account.ts
@@ -296,6 +296,7 @@ export const account: Messages["account"] = {
sizeHint: "Ctrl+= e Ctrl+- também mudam o tamanho, e Ctrl+0 volta ao padrão.",
language: "Idioma",
archived: "Arquivados",
+ security: "Segurança",
archivedIntro:
"Os agentes e as equipes que você arquivou estão parados e fora da vista, e o Botloft ainda guarda as conversas deles. Exclua um para tirá-lo de vez.",
archivedEmpty: "Nada está arquivado.",
diff --git a/app/src/i18n/pt-BR/index.ts b/app/src/i18n/pt-BR/index.ts
index 91154e0b..4d9b6c57 100644
--- a/app/src/i18n/pt-BR/index.ts
+++ b/app/src/i18n/pt-BR/index.ts
@@ -13,6 +13,7 @@ import { crews } from "./crews";
import { crewTemplates } from "./crewTemplates";
import { desktop } from "./desktop";
import { files } from "./files";
+import { keys } from "./keys";
import { messages } from "./messages";
import { onboarding } from "./onboarding";
import { phone } from "./phone";
@@ -42,6 +43,7 @@ export const ptBR: Messages = {
crewTemplates,
desktop,
files,
+ keys,
messages,
phone,
questions,
diff --git a/app/src/i18n/pt-BR/keys.ts b/app/src/i18n/pt-BR/keys.ts
new file mode 100644
index 00000000..95857864
--- /dev/null
+++ b/app/src/i18n/pt-BR/keys.ts
@@ -0,0 +1,75 @@
+// Chaves dos agentes (spec 32).
+
+import type { Messages } from "../en";
+
+export const keys: Messages["keys"] = {
+ card: {
+ asks: (bot: string) => `${bot} precisa de uma chave`,
+ why: "Para quê",
+ saved: (name: string) => `Você guardou a chave ${name}`,
+ dismissed: (name: string) => `Você não deu a chave ${name}`,
+ notNow: "Agora não",
+ notNowHint: (bot: string) => `Fecha o pedido e avisa ${bot} que a chave não vem agora.`,
+ dismissFailed: "Não foi possível fechar o pedido",
+ },
+ form: {
+ name: "Nome",
+ nameHint: "Letras maiúsculas, números e _, como GITHUB_TOKEN. O agente lê a chave por ele.",
+ value: "Chave",
+ valueHint: "Fica só neste computador, fora da conversa. Ninguém consegue ler de volta aqui.",
+ description: "Descrição (opcional)",
+ descriptionPlaceholder: "Para que serve",
+ show: "Mostrar a chave",
+ hide: "Esconder a chave",
+ save: "Salvar",
+ cancel: "Cancelar",
+ saveFailed: "Não foi possível salvar a chave",
+ restarts: (bot: string) => `${bot} reinicia para recebê-la, sem perder a conversa.`,
+ },
+ details: {
+ title: "Chaves",
+ none: (bot: string) =>
+ `Nenhuma chave ainda. Quando ${bot} precisar de um token ou senha de outro serviço, guarde aqui em vez de colar no chat.`,
+ howItWorks: "Como funciona",
+ explain: (bot: string) =>
+ `${bot} recebe cada chave como uma variável com o nome dela, e o Botloft troca pelo nome uma chave que aparece no chat. Não é uma garantia: se ${bot} imprimir a chave mesmo assim, ela fica no histórico do próprio motor. A melhor proteção é uma chave que pode pouco.`,
+ add: "Adicionar chave",
+ replace: (name: string) => `Trocar ${name}`,
+ remove: (name: string) => `Apagar ${name}`,
+ missing: "Falta o valor",
+ missingHint:
+ "O nome veio com uma cópia de segurança, o valor não. Troque para dar a chave de novo.",
+ changed: (when: string) => `mudou ${when}`,
+ removeTitle: (name: string) => `Apagar a chave ${name}?`,
+ removeBody: (bot: string) =>
+ `${bot} reinicia sem ela. A chave continua valendo no serviço de onde veio: para cancelar lá, revogue nesse serviço.`,
+ removeConfirm: "Apagar",
+ removeFailed: "Não foi possível apagar a chave",
+ },
+ scan: {
+ title: "Chaves coladas nas conversas",
+ intro:
+ "O Botloft procura no que você e seus agentes escreveram textos com cara de chave: tokens do GitHub, Slack, Stripe, OpenAI, Anthropic, Google, AWS e tokens de login. Mostra só o começo e o fim de cada uma.",
+ run: "Procurar chaves",
+ again: "Procurar de novo",
+ none: "Nenhuma chave encontrada nas conversas.",
+ found: (count: number) => (count === 1 ? "1 chave encontrada" : `${count} chaves encontradas`),
+ places: (count: number) => (count === 1 ? "em 1 lugar" : `em ${count} lugares`),
+ keep: "Guardar como chave",
+ keepTitle: "Guardar como chave de",
+ agent: "Agente",
+ goneAgent: "um agente que não existe mais",
+ remove: "Apagar do Botloft",
+ removeTitle: (preview: string) => `Apagar ${preview} do Botloft?`,
+ removeBody:
+ "Todo lugar onde ela aparece vai ficar [chave removida]. Não tem volta, e a chave continua valendo no serviço de onde veio.",
+ done: "Pronto",
+ failed: "Não foi possível procurar chaves",
+ scrubFailed: "Não foi possível mudar as conversas",
+ reachTitle: "Troque a chave no serviço de onde ela veio",
+ reach:
+ "Ela já passou por lugares que o Botloft não controla. O Botloft não mexe no histórico de conversa do próprio motor (começar uma conversa nova com o agente deixa ele para trás) nem nas cópias na nuvem já feitas (a próxima sai sem a chave; as antigas se apagam em Conta).",
+ },
+ pasted: (names: string[], bot: string) =>
+ `${names.length === 1 ? `A chave ${names[0]} já está guardada` : `As chaves ${names.join(", ")} já estão guardadas`}, então ${bot} recebeu o nome no lugar do valor.`,
+};
diff --git a/app/src/i18n/pt-BR/tools.ts b/app/src/i18n/pt-BR/tools.ts
index 9a2f6162..c739b66c 100644
--- a/app/src/i18n/pt-BR/tools.ts
+++ b/app/src/i18n/pt-BR/tools.ts
@@ -40,6 +40,7 @@ export const tools: Messages["tools"] = {
ask_crew_access: "Acessar outra equipe",
share_file: "Compartilhar um arquivo",
ask_owner: "Fazer uma pergunta a você",
+ ask_key: "Pedir uma chave a você",
show_plan: "Mostrar o plano a você",
browser: "Usar um site",
browser_help: "Pedir sua ajuda",
diff --git a/app/src/lib/fake.ts b/app/src/lib/fake.ts
index b2ec4dc0..cabf5ba6 100644
--- a/app/src/lib/fake.ts
+++ b/app/src/lib/fake.ts
@@ -17,6 +17,7 @@ import { crewHandlers } from "./fakeCrews";
import { FakeDesktop } from "./fakeDesktop";
import { FakeFiles } from "./fakeFiles";
import { islandHandlers } from "./fakeIsland";
+import { FakeKeys } from "./fakeKeys";
import { FakeLesson } from "./fakeLesson";
import { FakeMcp } from "./fakeMcp";
import { mobileHandlers } from "./fakeMobile";
@@ -102,6 +103,7 @@ export class FakeBotloft implements BotloftApi {
readonly browser = new FakeBrowser(this);
readonly screens = new FakeScreens(this);
readonly questions = new FakeQuestions(this);
+ readonly keys = new FakeKeys(this);
readonly reactions = new FakeReactions(this);
readonly lesson = new FakeLesson(this);
/** Clock for created and updated times. */
@@ -323,6 +325,7 @@ export class FakeBotloft implements BotloftApi {
...this.screens.handlers(),
...this.routines.handlers(),
...this.questions.handlers(),
+ ...this.keys.handlers(),
...this.reactions.handlers(),
...this.lesson.handlers(),
...usageHandlers(this),
diff --git a/app/src/lib/fakeChat.ts b/app/src/lib/fakeChat.ts
index 5a6ac2ac..b7f47a67 100644
--- a/app/src/lib/fakeChat.ts
+++ b/app/src/lib/fakeChat.ts
@@ -76,6 +76,10 @@ export function activityLine(
kind = "notice";
text = body.text;
break;
+ case "key_request":
+ kind = "question";
+ text = body.why;
+ break;
case "turn":
return null;
}
diff --git a/app/src/lib/fakeConversation.ts b/app/src/lib/fakeConversation.ts
index 42abff50..0bc02411 100644
--- a/app/src/lib/fakeConversation.ts
+++ b/app/src/lib/fakeConversation.ts
@@ -148,7 +148,7 @@ export class FakeConversation {
fromBotId: null,
toBotId: botId,
kind: "note",
- body: body.trim(),
+ body: this.fake.keys.redact(botId, body.trim()),
taskId: null,
routineId: null,
questionId: null,
diff --git a/app/src/lib/fakeKeys.test.ts b/app/src/lib/fakeKeys.test.ts
new file mode 100644
index 00000000..08544ee2
--- /dev/null
+++ b/app/src/lib/fakeKeys.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from "vitest";
+import { FakeBotloft } from "./fake";
+
+describe("the fake's agent keys", () => {
+ it("lists names without values, keeps a description and forgets a deleted key", async () => {
+ const fake = new FakeBotloft();
+ const ops = fake.addCrew("Ops");
+ const scout = fake.addBot(ops.id, "Scout", "Finds sources");
+ await fake.call("keys.set", {
+ botId: scout.id,
+ name: "WA_TOKEN",
+ value: "wa-123",
+ description: "WhatsApp",
+ });
+ const listed = await fake.call("keys.set", {
+ botId: scout.id,
+ name: "WA_TOKEN",
+ value: "wa-456",
+ });
+ expect(listed).toEqual([
+ { name: "WA_TOKEN", description: "WhatsApp", setAt: fake.now, missing: false },
+ ]);
+ expect(JSON.stringify(listed)).not.toContain("wa-456");
+ expect(await fake.call("keys.delete", { botId: scout.id, name: "WA_TOKEN" })).toEqual([]);
+ });
+
+ it("closes a bot's request when its key is saved or the owner says not now", async () => {
+ const fake = new FakeBotloft();
+ const ops = fake.addCrew("Ops");
+ const scout = fake.addBot(ops.id, "Scout", "Finds sources");
+ const first = fake.keys.ask(scout.id, "GITHUB_TOKEN", "To open the PR");
+ await expect(
+ fake.call("keys.set", { botId: scout.id, name: "OTHER", value: "x", requestId: first.id }),
+ ).rejects.toThrow();
+ await fake.call("keys.set", {
+ botId: scout.id,
+ name: "GITHUB_TOKEN",
+ value: "ghp-1",
+ requestId: first.id,
+ });
+ expect(first.body).toMatchObject({ kind: "key_request", status: "saved" });
+ const second = fake.keys.ask(scout.id, "STRIPE_KEY", "To read invoices");
+ await fake.call("keys.dismiss", { requestId: second.id });
+ expect(second.body).toMatchObject({ status: "dismissed" });
+ await expect(fake.call("keys.dismiss", { requestId: second.id })).rejects.toThrow();
+ });
+});
diff --git a/app/src/lib/fakeKeys.ts b/app/src/lib/fakeKeys.ts
new file mode 100644
index 00000000..27b8590f
--- /dev/null
+++ b/app/src/lib/fakeKeys.ts
@@ -0,0 +1,124 @@
+// The fake daemon's agent keys (spec 32): the names and descriptions it
+// lists, and the values only to tell which ones are set. A value is never
+// handed back. A bot's request for a key is a chat item, closed when the key
+// is saved with its id or when the owner says not now.
+
+import type { FakeBotloft, Handlers } from "./fake";
+import type {
+ AgentKey,
+ BotId,
+ ChatItem,
+ KeyHit,
+ KeyRequestItem,
+ KeyRequestStatus,
+} from "./protocol.gen";
+
+export class FakeKeys {
+ /** Each agent's keys by name; the value is kept only for tests to check. */
+ readonly saved = new Map>();
+
+ /**
+ * What `keys.scan` finds (spec 32.7), with the value behind each hit; the
+ * real daemon reads the conversations, here a test puts them in.
+ */
+ readonly pasted: (KeyHit & { value: string })[] = [];
+
+ constructor(private readonly fake: FakeBotloft) {}
+
+ private hits(): KeyHit[] {
+ return this.pasted.map(({ value: _value, ...hit }) => hit);
+ }
+
+ /** The bot asks the owner for a key, as `ask_key` does. */
+ ask(botId: BotId, name: string, why: string): ChatItem {
+ return this.fake.chat.add(botId, { kind: "key_request", name, why, status: "open" });
+ }
+
+ /**
+ * `text` with each of the bot's saved values, 8 characters or more and the
+ * longest first, swapped for `[chave NAME]`, as the daemon does (spec 32.6).
+ */
+ redact(botId: BotId, text: string): string {
+ const values = [...(this.saved.get(botId)?.values() ?? [])]
+ .filter((key) => key.value.length >= 8)
+ .sort((a, b) => b.value.length - a.value.length);
+ return values.reduce((done, key) => done.replaceAll(key.value, `[chave ${key.name}]`), text);
+ }
+
+ private list(botId: BotId): AgentKey[] {
+ this.fake.bot(botId);
+ const keys = [...(this.saved.get(botId)?.values() ?? [])];
+ return keys
+ .sort((a, b) => a.name.localeCompare(b.name))
+ .map(({ name, description, setAt, missing }) => ({ name, description, setAt, missing }));
+ }
+
+ /** The open request `id`, checked the way the daemon does. */
+ private request(id: string, botId?: BotId, name?: string): ChatItem & { body: KeyRequestItem } {
+ const item = this.fake.chat.items.find((entry) => entry.id === id);
+ if (item?.body.kind !== "key_request" || (botId && item.botId !== botId)) {
+ throw new Error(`request ${id} does not exist`);
+ }
+ if (item.body.status !== "open") {
+ throw new Error(`request ${id} was already answered`);
+ }
+ if (name && item.body.name !== name) {
+ throw new Error(`the request asks for ${item.body.name}, not ${name}`);
+ }
+ return item as ChatItem & { body: KeyRequestItem };
+ }
+
+ private close(item: ChatItem & { body: KeyRequestItem }, status: KeyRequestStatus): void {
+ this.fake.chat.update(item.id, { ...item.body, kind: "key_request", status });
+ }
+
+ handlers(): Pick<
+ Handlers,
+ "keys.list" | "keys.set" | "keys.delete" | "keys.dismiss" | "keys.scan" | "keys.scrub"
+ > {
+ return {
+ "keys.scan": () => this.hits(),
+ "keys.scrub": ({ hits }) => {
+ for (const { id, keep } of hits) {
+ const at = this.pasted.findIndex((hit) => hit.id === id);
+ const hit = this.pasted[at];
+ if (!hit) {
+ throw new Error(`hit ${id} is gone`);
+ }
+ if (keep) {
+ this.handlers()["keys.set"]({ botId: keep.botId, name: keep.name, value: hit.value });
+ }
+ this.pasted.splice(at, 1);
+ }
+ return this.hits();
+ },
+ "keys.list": ({ botId }) => this.list(botId),
+ "keys.set": ({ botId, name, value, description, requestId }) => {
+ this.fake.bot(botId);
+ const request = requestId ? this.request(requestId, botId, name) : null;
+ const keys = this.saved.get(botId) ?? new Map();
+ const before = keys.get(name);
+ keys.set(name, {
+ name,
+ value: value.trim(),
+ description: description ?? before?.description ?? "",
+ setAt: this.fake.now,
+ missing: false,
+ });
+ this.saved.set(botId, keys);
+ if (request) {
+ this.close(request, "saved");
+ }
+ return this.list(botId);
+ },
+ "keys.delete": ({ botId, name }) => {
+ this.saved.get(botId)?.delete(name);
+ return this.list(botId);
+ },
+ "keys.dismiss": ({ requestId }) => {
+ this.close(this.request(requestId), "dismissed");
+ return null;
+ },
+ };
+ }
+}
diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts
index 4d2b875c..483b4985 100644
--- a/app/src/lib/protocol.gen.ts
+++ b/app/src/lib/protocol.gen.ts
@@ -1685,6 +1685,81 @@ lines: EditLines,
*/
busiest: BusyBot | null, };
+/**
+ * A key of an agent, without its value (spec 32.2).
+ */
+export type AgentKey = { name: string,
+/**
+ * What the key is, as the owner wrote it; empty without one.
+ */
+description: string,
+/**
+ * When its value last changed, in Unix ms.
+ */
+setAt: number,
+/**
+ * The value is not on this computer, e.g. after restoring a copy.
+ */
+missing: boolean, };
+
+export type KeysListParams = { botId: BotId, };
+
+export type KeysSetParams = { botId: BotId, name: string, value: string, description?: string,
+/**
+ * The bot's request this answers (spec 32.5); its name must match.
+ */
+requestId?: ChatItemId, };
+
+export type KeysDeleteParams = { botId: BotId, name: string, };
+
+/**
+ * `keys.dismiss`: the owner does not give the key now.
+ */
+export type KeysDismissParams = { requestId: ChatItemId, };
+
+/**
+ * Something in the conversations that looks like a pasted key (spec 32.7),
+ * once per value however many times it shows up.
+ */
+export type KeyHit = {
+/**
+ * Opaque; names the value to `keys.scrub` without carrying it.
+ */
+id: string,
+/**
+ * How it starts and its last 4 characters, like `sk-…a1b2`.
+ */
+preview: string,
+/**
+ * The agents whose conversations have it, the latest first.
+ */
+botIds: Array,
+/**
+ * How many texts have it: messages, chat items, requests, questions.
+ */
+places: number,
+/**
+ * The newest of those, in Unix ms.
+ */
+lastAt: number, };
+
+export type KeyKeep = { botId: BotId, name: string, };
+
+/**
+ * What to do with one hit: keep it as a key of an agent, or just take it
+ * out of Botloft.
+ */
+export type KeyScrub = { id: string,
+/**
+ * The agent and the name to save it as; `null` deletes it.
+ */
+keep: KeyKeep | null, };
+
+/**
+ * `keys.scrub`; answers with what is left to scan.
+ */
+export type KeysScrubParams = { hits: Array, };
+
export type ApprovalItem = { approvalId: ApprovalId, toolName: string, summary: string,
/**
* What the bot says the command is for, as in `ToolItem`. The bot
@@ -1853,10 +1928,25 @@ code?: NoticeCode,
*/
text: string, };
+/**
+ * Where a request for a key stands (spec 32.5).
+ */
+export type KeyRequestStatus = "open" | "saved" | "dismissed";
+
+export type KeyRequestItem = {
+/**
+ * The key's name, as in spec 32.3.
+ */
+name: string,
+/**
+ * What the bot needs it for, in its own words.
+ */
+why: string, status: KeyRequestStatus, };
+
/**
* What a chat item holds, by `kind`.
*/
-export type ChatBody = { "kind": "inbound" } & InboundItem | { "kind": "reply" } & ReplyItem | { "kind": "tool" } & ToolItem | { "kind": "approval" } & ApprovalItem | { "kind": "question" } & QuestionItem | { "kind": "turn" } & TurnItem | { "kind": "notice" } & NoticeItem;
+export type ChatBody = { "kind": "inbound" } & InboundItem | { "kind": "reply" } & ReplyItem | { "kind": "tool" } & ToolItem | { "kind": "approval" } & ApprovalItem | { "kind": "question" } & QuestionItem | { "kind": "turn" } & TurnItem | { "kind": "notice" } & NoticeItem | { "kind": "key_request" } & KeyRequestItem;
/**
* One entry of a bot's chat.
@@ -2431,6 +2521,12 @@ export interface RpcMethods {
"mobile.revoke": { params: MobileRevokeParams; result: MobileStatus };
"cards.list": { params: undefined; result: Cards };
"activity.today": { params: ActivityTodayParams; result: DayActivity };
+ "keys.list": { params: KeysListParams; result: Array };
+ "keys.set": { params: KeysSetParams; result: Array };
+ "keys.delete": { params: KeysDeleteParams; result: Array };
+ "keys.dismiss": { params: KeysDismissParams; result: null };
+ "keys.scan": { params: undefined; result: Array };
+ "keys.scrub": { params: KeysScrubParams; result: Array };
"routines.list": { params: RoutinesListParams; result: Array };
"routines.create": { params: RoutinesCreateParams; result: Routine };
"routines.update": { params: RoutinesUpdateParams; result: Routine };
diff --git a/app/src/ui/Field.tsx b/app/src/ui/Field.tsx
index 419edf8a..34cb39db 100644
--- a/app/src/ui/Field.tsx
+++ b/app/src/ui/Field.tsx
@@ -1,9 +1,11 @@
+import { Eye, EyeOff } from "lucide-react";
import {
type InputHTMLAttributes,
type ReactNode,
type SelectHTMLAttributes,
type TextareaHTMLAttributes,
useId,
+ useState,
} from "react";
interface Framing {
@@ -59,6 +61,45 @@ export function TextField({
);
}
+/**
+ * A password-like field, with an eye that shows what was typed. `show` and
+ * `hide` name the eye's two states.
+ */
+export function SecretField({
+ label,
+ hint,
+ show,
+ hide,
+ ...input
+}: Framing & { show: string; hide: string } & InputHTMLAttributes) {
+ const id = useId();
+ const [shown, setShown] = useState(false);
+ const Icon = shown ? EyeOff : Eye;
+ return (
+
+
+
+ );
+}
+
export function TextArea({
label,
hint,
diff --git a/crates/botloft-core/src/chat.rs b/crates/botloft-core/src/chat.rs
index 09fb732b..8fc36e8c 100644
--- a/crates/botloft-core/src/chat.rs
+++ b/crates/botloft-core/src/chat.rs
@@ -190,6 +190,8 @@ pub fn activity(body: &ChatBody, at: i64) -> Option {
item.explanation.as_ref().unwrap_or(&item.summary).clone(),
),
ChatBody::Question(item) => (ActivityKind::Question, item.question.text.clone()),
+ // Asking for a key is asking the owner (spec 32.5).
+ ChatBody::KeyRequest(item) => (ActivityKind::Question, item.why.clone()),
// A compaction is housekeeping, not news about the conversation.
ChatBody::Notice(item)
if matches!(
diff --git a/crates/botloft-core/src/key_scan.rs b/crates/botloft-core/src/key_scan.rs
new file mode 100644
index 00000000..e4b06476
--- /dev/null
+++ b/crates/botloft-core/src/key_scan.rs
@@ -0,0 +1,165 @@
+//! Text that looks like a key someone pasted (spec 32.7): a known prefix
+//! followed by a long run of token characters, or a JWT. Works on raw text
+//! and on the JSON a chat item is kept as.
+
+/// Prefixes of keys from well-known services.
+pub const PREFIXES: &[&str] = &[
+ "sk-ant-",
+ "sk_live_",
+ "rk_live_",
+ "github_pat_",
+ "ghp_",
+ "xoxb-",
+ "xoxp-",
+ "whsec_",
+ "AKIA",
+ "AIza",
+ "sk-",
+];
+/// Characters after the prefix for it to count, so "sk-learn" does not.
+pub const BODY_MIN: usize = 16;
+const JWT_START: &str = "eyJ";
+
+/// One key found: its value and how it starts, the part shown to the owner.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Found<'a> {
+ pub value: &'a str,
+ pub prefix: &'static str,
+}
+
+impl Found<'_> {
+ /// The start and the last 4 characters, never the whole value.
+ pub fn preview(&self) -> String {
+ let tail = &self.value[self.value.len().saturating_sub(4)..];
+ format!("{}…{tail}", self.prefix)
+ }
+}
+
+fn token_char(byte: u8) -> bool {
+ byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-'
+}
+
+/// Whether a key may start at `at`: not inside a word, except after an
+/// escape in JSON text ("\nghp_...").
+fn starts_word(bytes: &[u8], at: usize) -> bool {
+ let Some(&before) = at.checked_sub(1).and_then(|i| bytes.get(i)) else {
+ return true;
+ };
+ !token_char(before)
+ || (at >= 2 && bytes[at - 2] == b'\\' && matches!(before, b'n' | b't' | b'r'))
+}
+
+fn run(bytes: &[u8], from: usize) -> usize {
+ let mut end = from;
+ while bytes.get(end).copied().is_some_and(token_char) {
+ end += 1;
+ }
+ end
+}
+
+fn prefixed(bytes: &[u8], at: usize) -> Option<(usize, &'static str)> {
+ let rest = &bytes[at..];
+ let prefix = PREFIXES.iter().find(|p| rest.starts_with(p.as_bytes()))?;
+ let end = run(bytes, at + prefix.len());
+ (end - at - prefix.len() >= BODY_MIN).then_some((end, *prefix))
+}
+
+/// Three base64url parts joined by dots, the first one a JSON header.
+fn jwt(bytes: &[u8], at: usize) -> Option {
+ if !bytes[at..].starts_with(JWT_START.as_bytes()) {
+ return None;
+ }
+ // Each part: where it ends, and whether it is long enough.
+ let part = |from: usize, min: usize| {
+ let end = run(bytes, from);
+ (end - from >= min).then_some(end)
+ };
+ let dot = |end: usize| (bytes.get(end) == Some(&b'.')).then_some(end + 1);
+ let second = part(at, 10).and_then(dot)?;
+ let third = part(second, 4).and_then(dot)?;
+ part(third, 4)
+}
+
+/// The keys in `text`, in order.
+pub fn find(text: &str) -> Vec> {
+ let bytes = text.as_bytes();
+ let mut found = Vec::new();
+ let mut at = 0;
+ while at < bytes.len() {
+ if starts_word(bytes, at) {
+ let hit = jwt(bytes, at)
+ .map(|end| (end, JWT_START))
+ .or_else(|| prefixed(bytes, at));
+ // Only ASCII bytes match, so `at` and `end` are char boundaries.
+ if let Some((end, prefix)) = hit {
+ found.push(Found {
+ value: &text[at..end],
+ prefix,
+ });
+ at = end;
+ continue;
+ }
+ }
+ at += 1;
+ }
+ found
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn values(text: &str) -> Vec<&str> {
+ find(text).into_iter().map(|f| f.value).collect()
+ }
+
+ #[test]
+ fn each_known_prefix_is_found_with_a_long_enough_body() {
+ let body = "a1B2c3D4e5F6g7H8";
+ for prefix in PREFIXES {
+ let key = format!("{prefix}{body}");
+ let text = format!("use {key}, thanks");
+ assert_eq!(values(&text), [key.as_str()], "{prefix}");
+ }
+ assert_eq!(values("AKIAIOSFODNN7EXAMPLE"), ["AKIAIOSFODNN7EXAMPLE"]);
+ assert_eq!(
+ values("sk-ant-api03-abcdefghijklmnop_QRST-uvw"),
+ ["sk-ant-api03-abcdefghijklmnop_QRST-uvw"]
+ );
+ }
+
+ #[test]
+ fn words_and_short_runs_are_not_keys() {
+ for text in [
+ "task-runner and risk-free",
+ "pip install sk-learn",
+ "ghp_short",
+ "the [chave GITHUB_TOKEN] is kept",
+ "eyJhbGciOiJIUzI1NiJ9 without dots",
+ "ASKIA is a name",
+ ] {
+ assert!(find(text).is_empty(), "{text}");
+ }
+ }
+
+ #[test]
+ fn a_jwt_is_found_whole() {
+ let jwt =
+ "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U";
+ assert_eq!(values(&format!("Bearer {jwt}.")), [jwt]);
+ }
+
+ #[test]
+ fn keys_inside_json_text_and_after_an_escape_are_found() {
+ let key = "ghp_abcdefghijklmnopqrstuvwxyz0123";
+ let json = format!(r#"{{"text":"line one\n{key}\tnext","output":"{key}"}}"#);
+ assert_eq!(values(&json), [key, key]);
+ }
+
+ #[test]
+ fn the_preview_shows_the_start_and_the_end_only() {
+ let found = find(concat!("xox", "b-1234567890-abcdefghij"))[0];
+ assert_eq!(found.preview(), "xoxb-…ghij");
+ assert_eq!(find("é sk-0123456789abcdefWXYZ")[0].preview(), "sk-…WXYZ");
+ }
+}
diff --git a/crates/botloft-core/src/keys.rs b/crates/botloft-core/src/keys.rs
new file mode 100644
index 00000000..5421a6a5
--- /dev/null
+++ b/crates/botloft-core/src/keys.rs
@@ -0,0 +1,165 @@
+//! The rules of an agent's keys (spec 32): what a key may be called, what
+//! its value may be, and how a value that shows up in a text is replaced by
+//! the key's name before the daemon keeps that text.
+
+use crate::validate::ValidationError;
+
+/// Longest key name, in characters (spec 32.3).
+pub const KEY_NAME_MAX: usize = 64;
+/// Longest key value, in bytes: tokens and certificates fit.
+pub const KEY_VALUE_MAX: usize = 16 * 1024;
+/// Longest description, in characters.
+pub const KEY_DESCRIPTION_MAX: usize = 200;
+/// Shorter values are not looked for in texts: they would match words.
+pub const REDACT_MIN_LEN: usize = 8;
+
+/// Names the system, the Botloft or the engine uses (spec 32.3).
+const RESERVED: &[&str] = &[
+ "PATH",
+ "PATHEXT",
+ "SYSTEMROOT",
+ "COMSPEC",
+ "TEMP",
+ "TMP",
+ "USERPROFILE",
+ "HOME",
+ "APPDATA",
+ "LOCALAPPDATA",
+];
+const RESERVED_PREFIXES: &[&str] = &[
+ "BOTLOFT_",
+ "CLAUDE",
+ "ANTHROPIC_",
+ "CODEX_",
+ "OPENAI_",
+ "NODE_",
+];
+
+/// Checks a key name: `[A-Z][A-Z0-9_]{1,63}`, not one of the reserved ones.
+pub fn name(value: &str) -> Result {
+ let value = value.trim();
+ let mut chars = value.chars();
+ let shape = chars.next().is_some_and(|first| first.is_ascii_uppercase())
+ && chars.all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
+ && (2..=KEY_NAME_MAX).contains(&value.len());
+ if !shape {
+ return Err(ValidationError(format!(
+ "name must be 2 to {KEY_NAME_MAX} capital letters, digits or _, starting with a letter"
+ )));
+ }
+ if RESERVED.contains(&value)
+ || RESERVED_PREFIXES
+ .iter()
+ .any(|prefix| value.starts_with(prefix))
+ {
+ return Err(ValidationError(format!(
+ "name {value} is used by the system, Botloft or the engine; pick another"
+ )));
+ }
+ Ok(value.to_owned())
+}
+
+/// Checks a key value: not empty, one line, within the size.
+pub fn value(value: &str) -> Result {
+ let value = value.trim();
+ if value.is_empty() {
+ return Err(ValidationError("value must not be empty".to_owned()));
+ }
+ if value.len() > KEY_VALUE_MAX {
+ return Err(ValidationError(format!(
+ "value must be at most {KEY_VALUE_MAX} bytes"
+ )));
+ }
+ if value.contains(['\n', '\r', '\0']) {
+ return Err(ValidationError("value must be a single line".to_owned()));
+ }
+ Ok(value.to_owned())
+}
+
+/// Checks a description, a single line that may be empty.
+pub fn description(value: &str) -> Result {
+ let value = value.trim();
+ if value.chars().count() > KEY_DESCRIPTION_MAX || value.contains(['\n', '\r']) {
+ return Err(ValidationError(format!(
+ "description must be one line of at most {KEY_DESCRIPTION_MAX} characters"
+ )));
+ }
+ Ok(value.to_owned())
+}
+
+/// `text` with every value of `keys` (name, value) replaced by `[chave NAME]`,
+/// longest values first so one that contains another goes whole. Values
+/// shorter than `REDACT_MIN_LEN` are left alone. `None` when nothing changed.
+pub fn redact(text: &str, keys: &[(String, String)]) -> Option {
+ let mut found: Vec<&(String, String)> = keys
+ .iter()
+ .filter(|(_, value)| value.len() >= REDACT_MIN_LEN && text.contains(value.as_str()))
+ .collect();
+ if found.is_empty() {
+ return None;
+ }
+ found.sort_by_key(|(_, value)| std::cmp::Reverse(value.len()));
+ let mut out = text.to_owned();
+ for (name, value) in found {
+ out = out.replace(value.as_str(), &format!("[chave {name}]"));
+ }
+ Some(out)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn names_are_capitals_and_not_the_systems() {
+ assert_eq!(name(" WHATSAPP_TOKEN ").as_deref(), Ok("WHATSAPP_TOKEN"));
+ assert!(name("STRIPE_KEY_2").is_ok());
+ for bad in [
+ "",
+ "A",
+ "whatsapp",
+ "2FA_CODE",
+ "MY-KEY",
+ "MY KEY",
+ "ÁGUA_KEY",
+ ] {
+ assert!(name(bad).is_err(), "{bad}");
+ }
+ for reserved in [
+ "PATH",
+ "TEMP",
+ "BOTLOFT_X",
+ "CLAUDE_CODE_X",
+ "ANTHROPIC_API_KEY",
+ "NODE_OPTIONS",
+ ] {
+ assert!(name(reserved).is_err(), "{reserved}");
+ }
+ }
+
+ #[test]
+ fn values_are_one_line_and_not_empty() {
+ assert_eq!(value(" sk-abc123 ").as_deref(), Ok("sk-abc123"));
+ assert!(value(" ").is_err());
+ assert!(value("a\nb").is_err());
+ assert!(value(&"x".repeat(KEY_VALUE_MAX + 1)).is_err());
+ assert!(description("Token do gateway").is_ok());
+ assert!(description("two\nlines").is_err());
+ }
+
+ #[test]
+ fn values_in_a_text_become_the_name() {
+ let keys = vec![
+ ("WA_TOKEN".to_owned(), "wa-1234567890".to_owned()),
+ ("SHORT".to_owned(), "abc".to_owned()),
+ ("LONG".to_owned(), "wa-1234567890-extra".to_owned()),
+ ];
+ assert_eq!(
+ redact("token=wa-1234567890 and wa-1234567890-extra", &keys).as_deref(),
+ Some("token=[chave WA_TOKEN] and [chave LONG]")
+ );
+ // Too short to look for, and nothing to change.
+ assert_eq!(redact("abc def", &keys), None);
+ assert_eq!(redact("nothing here", &keys), None);
+ }
+}
diff --git a/crates/botloft-core/src/lib.rs b/crates/botloft-core/src/lib.rs
index 9fd9841d..01cc720c 100644
--- a/crates/botloft-core/src/lib.rs
+++ b/crates/botloft-core/src/lib.rs
@@ -7,6 +7,8 @@ pub mod command;
pub mod edits;
pub mod envelope;
pub mod ids;
+pub mod key_scan;
+pub mod keys;
pub mod protocol;
pub mod slug;
pub mod validate;
diff --git a/crates/botloft-core/src/protocol/chat.rs b/crates/botloft-core/src/protocol/chat.rs
index 62e4bcf5..50319457 100644
--- a/crates/botloft-core/src/protocol/chat.rs
+++ b/crates/botloft-core/src/protocol/chat.rs
@@ -102,6 +102,29 @@ pub enum ChatBody {
/// The end of a turn.
Turn(TurnItem),
Notice(NoticeItem),
+ /// The bot asks the owner for a key (spec 32.5); the item's id is the
+ /// request's.
+ KeyRequest(KeyRequestItem),
+}
+
+text_enum!(
+ /// Where a request for a key stands (spec 32.5).
+ KeyRequestStatus, "key request status" {
+ Open => "open",
+ Saved => "saved",
+ Dismissed => "dismissed",
+ }
+);
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+#[cfg_attr(test, derive(ts_rs::TS))]
+pub struct KeyRequestItem {
+ /// The key's name, as in spec 32.3.
+ pub name: String,
+ /// What the bot needs it for, in its own words.
+ pub why: String,
+ pub status: KeyRequestStatus,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs
index 0469553d..71c1efa8 100644
--- a/crates/botloft-core/src/protocol/export.rs
+++ b/crates/botloft-core/src/protocol/export.rs
@@ -28,6 +28,8 @@ mod crew_access;
mod desktop;
#[path = "export_island.rs"]
mod island;
+#[path = "export_keys.rs"]
+mod keys;
#[path = "export_mcp.rs"]
mod mcp;
#[path = "export_mobile.rs"]
@@ -213,12 +215,15 @@ fn export_bindings() {
cloud::decls(&mut out);
mobile::decls(&mut out);
island::decls(&mut out);
+ keys::decls(&mut out);
out.decl::();
usage::decls(&mut out);
questions::decls(&mut out);
reactions::decls(&mut out);
out.decl::();
out.decl::();
+ out.decl::();
+ out.decl::();
out.decl::();
out.decl::();
out.decl::();
@@ -415,6 +420,7 @@ fn export_bindings() {
cloud::methods(&mut out);
mobile::methods(&mut out);
island::methods(&mut out);
+ keys::methods(&mut out);
routines::methods(&mut out);
questions::methods(&mut out);
reactions::methods(&mut out);
diff --git a/crates/botloft-core/src/protocol/export_keys.rs b/crates/botloft-core/src/protocol/export_keys.rs
new file mode 100644
index 00000000..60c75eff
--- /dev/null
+++ b/crates/botloft-core/src/protocol/export_keys.rs
@@ -0,0 +1,31 @@
+//! The agents' keys part of the TypeScript bindings (spec 32).
+
+use super::super::*;
+use super::Out;
+
+pub(super) fn decls(out: &mut Out) {
+ out.decl::();
+ out.decl::();
+ out.decl::();
+ out.decl::();
+ out.decl::();
+ out.decl::();
+ out.decl::();
+ out.decl::();
+ out.decl::();
+}
+
+pub(super) fn methods(out: &mut Out) {
+ let keys = out.name::>();
+ out.method(method::KEYS_LIST, &out.name::(), &keys);
+ out.method(method::KEYS_SET, &out.name::(), &keys);
+ out.method(method::KEYS_DELETE, &out.name::(), &keys);
+ out.method(
+ method::KEYS_DISMISS,
+ &out.name::(),
+ "null",
+ );
+ let hits = out.name::>();
+ out.method(method::KEYS_SCAN, "undefined", &hits);
+ out.method(method::KEYS_SCRUB, &out.name::(), &hits);
+}
diff --git a/crates/botloft-core/src/protocol/keys.rs b/crates/botloft-core/src/protocol/keys.rs
new file mode 100644
index 00000000..da242353
--- /dev/null
+++ b/crates/botloft-core/src/protocol/keys.rs
@@ -0,0 +1,104 @@
+//! An agent's keys (spec 32): the names the app lists. A key's value goes in
+//! with `keys.set` and never comes back out.
+
+use serde::{Deserialize, Serialize};
+
+use crate::ids::{BotId, ChatItemId};
+
+/// A key of an agent, without its value (spec 32.2).
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+#[cfg_attr(test, derive(ts_rs::TS))]
+pub struct AgentKey {
+ pub name: String,
+ /// What the key is, as the owner wrote it; empty without one.
+ pub description: String,
+ /// When its value last changed, in Unix ms.
+ pub set_at: i64,
+ /// The value is not on this computer, e.g. after restoring a copy.
+ pub missing: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+#[cfg_attr(test, derive(ts_rs::TS))]
+pub struct KeysListParams {
+ pub bot_id: BotId,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+#[cfg_attr(test, derive(ts_rs::TS))]
+pub struct KeysSetParams {
+ pub bot_id: BotId,
+ pub name: String,
+ pub value: String,
+ #[serde(default)]
+ #[cfg_attr(test, ts(optional))]
+ pub description: Option,
+ /// The bot's request this answers (spec 32.5); its name must match.
+ #[serde(default)]
+ #[cfg_attr(test, ts(optional))]
+ pub request_id: Option,
+}
+
+/// `keys.dismiss`: the owner does not give the key now.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+#[cfg_attr(test, derive(ts_rs::TS))]
+pub struct KeysDismissParams {
+ pub request_id: ChatItemId,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+#[cfg_attr(test, derive(ts_rs::TS))]
+pub struct KeysDeleteParams {
+ pub bot_id: BotId,
+ pub name: String,
+}
+
+/// Something in the conversations that looks like a pasted key (spec 32.7),
+/// once per value however many times it shows up.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+#[cfg_attr(test, derive(ts_rs::TS))]
+pub struct KeyHit {
+ /// Opaque; names the value to `keys.scrub` without carrying it.
+ pub id: String,
+ /// How it starts and its last 4 characters, like `sk-…a1b2`.
+ pub preview: String,
+ /// The agents whose conversations have it, the latest first.
+ pub bot_ids: Vec,
+ /// How many texts have it: messages, chat items, requests, questions.
+ pub places: u32,
+ /// The newest of those, in Unix ms.
+ pub last_at: i64,
+}
+
+/// What to do with one hit: keep it as a key of an agent, or just take it
+/// out of Botloft.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+#[cfg_attr(test, derive(ts_rs::TS))]
+pub struct KeyScrub {
+ pub id: String,
+ /// The agent and the name to save it as; `null` deletes it.
+ pub keep: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+#[cfg_attr(test, derive(ts_rs::TS))]
+pub struct KeyKeep {
+ pub bot_id: BotId,
+ pub name: String,
+}
+
+/// `keys.scrub`; answers with what is left to scan.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+#[cfg_attr(test, derive(ts_rs::TS))]
+pub struct KeysScrubParams {
+ pub hits: Vec,
+}
diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs
index 8d82c955..8b363895 100644
--- a/crates/botloft-core/src/protocol/mod.rs
+++ b/crates/botloft-core/src/protocol/mod.rs
@@ -61,6 +61,7 @@ mod desktop;
mod export;
mod files;
mod island;
+mod keys;
mod lesson;
mod mcp;
mod messaging;
@@ -87,6 +88,7 @@ pub use crew_access::*;
pub use desktop::*;
pub use files::*;
pub use island::*;
+pub use keys::*;
pub use lesson::*;
pub use mcp::*;
pub use messaging::*;
@@ -218,6 +220,12 @@ pub mod method {
pub const SCREENS_LIST: &str = "screens.list";
pub const CARDS_LIST: &str = "cards.list";
pub const ACTIVITY_TODAY: &str = "activity.today";
+ pub const KEYS_LIST: &str = "keys.list";
+ pub const KEYS_SET: &str = "keys.set";
+ pub const KEYS_DELETE: &str = "keys.delete";
+ pub const KEYS_DISMISS: &str = "keys.dismiss";
+ pub const KEYS_SCAN: &str = "keys.scan";
+ pub const KEYS_SCRUB: &str = "keys.scrub";
pub const QUESTIONS_LIST: &str = "questions.list";
pub const QUESTIONS_ANSWER: &str = "questions.answer";
pub const QUESTIONS_DISMISS: &str = "questions.dismiss";
diff --git a/crates/botloft-store/migrations/0032_agent_keys.sql b/crates/botloft-store/migrations/0032_agent_keys.sql
new file mode 100644
index 00000000..8df11b50
--- /dev/null
+++ b/crates/botloft-store/migrations/0032_agent_keys.sql
@@ -0,0 +1,9 @@
+-- The names of an agent's keys (spec 32.2). Their values live in
+-- secrets\keys\.json, never here.
+CREATE TABLE agent_keys (
+ bot_id TEXT NOT NULL REFERENCES bots (id),
+ name TEXT NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ set_at INTEGER NOT NULL,
+ PRIMARY KEY (bot_id, name)
+);
diff --git a/crates/botloft-store/src/agent_keys.rs b/crates/botloft-store/src/agent_keys.rs
new file mode 100644
index 00000000..62c58abc
--- /dev/null
+++ b/crates/botloft-store/src/agent_keys.rs
@@ -0,0 +1,99 @@
+//! The names of an agent's keys (spec 32.2): what the app lists. The values
+//! are in `secrets\keys`, never here.
+
+use botloft_core::ids::BotId;
+use botloft_core::protocol::AgentKey;
+use rusqlite::params;
+
+use crate::{Result, Store};
+
+impl Store {
+ /// The agent's keys by name, every one marked as present: the daemon
+ /// knows which values are missing.
+ pub fn agent_keys(&self, bot: &BotId) -> Result> {
+ let mut stmt = self.conn.prepare_cached(
+ "SELECT name, description, set_at FROM agent_keys WHERE bot_id = ?1 ORDER BY name",
+ )?;
+ let rows = stmt.query_map(params![bot.to_string()], |row| {
+ Ok(AgentKey {
+ name: row.get(0)?,
+ description: row.get(1)?,
+ set_at: row.get(2)?,
+ missing: false,
+ })
+ })?;
+ Ok(rows.collect::>()?)
+ }
+
+ /// Adds a key or marks it changed; a `None` description keeps the old one.
+ pub fn set_agent_key(
+ &self,
+ bot: &BotId,
+ name: &str,
+ description: Option<&str>,
+ at: i64,
+ ) -> Result<()> {
+ self.conn.execute(
+ "INSERT INTO agent_keys (bot_id, name, description, set_at) \
+ VALUES (?1, ?2, COALESCE(?3, ''), ?4) \
+ ON CONFLICT (bot_id, name) DO UPDATE SET \
+ description = COALESCE(?3, description), set_at = ?4",
+ params![bot.to_string(), name, description, at],
+ )?;
+ Ok(())
+ }
+
+ /// How many of the bot's requests for a key the owner has not answered
+ /// (spec 32.5).
+ pub fn open_key_requests(&self, bot: &BotId) -> Result {
+ let open: i64 = self.conn.query_row(
+ "SELECT COUNT(*) FROM chat_items \
+ WHERE bot_id = ?1 AND kind = 'key_request' \
+ AND json_extract(data, '$.status') = 'open'",
+ params![bot.to_string()],
+ |row| row.get(0),
+ )?;
+ Ok(u32::try_from(open).unwrap_or(u32::MAX))
+ }
+
+ /// Whether there was such a key.
+ pub fn delete_agent_key(&self, bot: &BotId, name: &str) -> Result {
+ let gone = self.conn.execute(
+ "DELETE FROM agent_keys WHERE bot_id = ?1 AND name = ?2",
+ params![bot.to_string(), name],
+ )?;
+ Ok(gone > 0)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::tests::Fixture;
+
+ #[test]
+ fn names_are_kept_and_a_new_value_keeps_the_description() {
+ let fx = Fixture::new();
+ let bot = &fx.bots[0].id;
+ fx.store
+ .set_agent_key(bot, "WA_TOKEN", Some("WhatsApp"), 10)
+ .expect("set");
+ fx.store.set_agent_key(bot, "A_KEY", None, 11).expect("set");
+ fx.store
+ .set_agent_key(bot, "WA_TOKEN", None, 20)
+ .expect("again");
+ let keys = fx.store.agent_keys(bot).expect("list");
+ let seen: Vec<_> = keys
+ .iter()
+ .map(|k| (k.name.as_str(), k.description.as_str(), k.set_at))
+ .collect();
+ assert_eq!(seen, [("A_KEY", "", 11), ("WA_TOKEN", "WhatsApp", 20)]);
+ assert!(
+ fx.store
+ .agent_keys(&fx.bots[1].id)
+ .expect("other")
+ .is_empty()
+ );
+ assert!(fx.store.delete_agent_key(bot, "A_KEY").expect("delete"));
+ assert!(!fx.store.delete_agent_key(bot, "A_KEY").expect("again"));
+ }
+}
diff --git a/crates/botloft-store/src/chat.rs b/crates/botloft-store/src/chat.rs
index b3dd7a06..f09775d6 100644
--- a/crates/botloft-store/src/chat.rs
+++ b/crates/botloft-store/src/chat.rs
@@ -34,6 +34,7 @@ fn kind(body: &ChatBody) -> &'static str {
ChatBody::Question(_) => "question",
ChatBody::Turn(_) => "turn",
ChatBody::Notice(_) => "notice",
+ ChatBody::KeyRequest(_) => "key_request",
}
}
diff --git a/crates/botloft-store/src/delete.rs b/crates/botloft-store/src/delete.rs
index b058e33f..ed10d852 100644
--- a/crates/botloft-store/src/delete.rs
+++ b/crates/botloft-store/src/delete.rs
@@ -21,6 +21,7 @@ const DELETE_BOT: &[&str] = &[
"DELETE FROM browser_sites WHERE bot_id = ?1",
"DELETE FROM desktop_grants WHERE bot_id = ?1",
"DELETE FROM bot_mcp_servers WHERE bot_id = ?1",
+ "DELETE FROM agent_keys WHERE bot_id = ?1",
"DELETE FROM turn_costs 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",
diff --git a/crates/botloft-store/src/key_scan.rs b/crates/botloft-store/src/key_scan.rs
new file mode 100644
index 00000000..d8e085af
--- /dev/null
+++ b/crates/botloft-store/src/key_scan.rs
@@ -0,0 +1,220 @@
+//! Keys pasted into the conversations before the owner kept them apart
+//! (spec 32.7): finding them in every text Botloft keeps and swapping them
+//! for a name. The chat search index follows through its triggers.
+
+use std::collections::HashMap;
+
+use botloft_core::ids::BotId;
+use botloft_core::key_scan::{self, PREFIXES};
+use rusqlite::params;
+use sha2::{Digest, Sha256};
+
+use crate::{Result, Store};
+
+/// Every text column with what the owner or a bot wrote: table, the bot
+/// it belongs to, the text, and when.
+const PLACES: &[(&str, &str, &str, &str)] = &[
+ ("messages", "to_bot_id", "body", "created_at"),
+ ("chat_items", "bot_id", "data", "updated_at"),
+ ("approvals", "bot_id", "input", "created_at"),
+ ("approvals", "bot_id", "summary", "created_at"),
+ ("approvals", "bot_id", "note", "created_at"),
+ ("questions", "bot_id", "text", "created_at"),
+ ("questions", "bot_id", "answer", "created_at"),
+ ("tasks", "assignee_bot_id", "result", "updated_at"),
+];
+
+/// A value found, with where it is.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Pasted {
+ /// Names the value without carrying it.
+ pub id: String,
+ pub value: String,
+ pub preview: String,
+ /// The agents whose texts have it, the latest first.
+ pub bots: Vec,
+ pub places: u32,
+ pub last_at: i64,
+}
+
+/// The id of a value: the start of its SHA-256, in hex.
+pub fn pasted_id(value: &str) -> String {
+ Sha256::digest(value.as_bytes())
+ .iter()
+ .take(8)
+ .map(|byte| format!("{byte:02x}"))
+ .collect()
+}
+
+/// Rows worth reading: those with one of the starts. LIKE also takes `_`
+/// as any character, which only lets a few more rows through.
+fn maybe(column: &str) -> String {
+ PREFIXES
+ .iter()
+ .chain(std::iter::once(&"eyJ"))
+ .map(|prefix| format!("{column} LIKE '%{prefix}%'"))
+ .collect::>()
+ .join(" OR ")
+}
+
+struct Seen {
+ pasted: Pasted,
+ bots: HashMap,
+}
+
+impl Store {
+ /// Everything in the conversations that looks like a key, the newest
+ /// first.
+ pub fn pasted_keys(&self) -> Result> {
+ let mut seen: HashMap = HashMap::new();
+ for (table, bot, column, at) in PLACES {
+ let sql = format!(
+ "SELECT {bot}, {column}, {at} FROM {table} \
+ WHERE {column} IS NOT NULL AND ({})",
+ maybe(column)
+ );
+ let mut stmt = self.conn.prepare(&sql)?;
+ let mut rows = stmt.query([])?;
+ while let Some(row) = rows.next()? {
+ let bot: String = row.get(0)?;
+ let text: String = row.get(1)?;
+ let at: i64 = row.get(2)?;
+ let mut counted = Vec::new();
+ for found in key_scan::find(&text) {
+ if counted.contains(&found.value) {
+ continue;
+ }
+ counted.push(found.value);
+ let entry = seen.entry(found.value.to_owned()).or_insert_with(|| Seen {
+ pasted: Pasted {
+ id: pasted_id(found.value),
+ value: found.value.to_owned(),
+ preview: found.preview(),
+ bots: Vec::new(),
+ places: 0,
+ last_at: at,
+ },
+ bots: HashMap::new(),
+ });
+ entry.pasted.places += 1;
+ entry.pasted.last_at = entry.pasted.last_at.max(at);
+ let latest = entry.bots.entry(bot.clone()).or_insert(at);
+ *latest = (*latest).max(at);
+ }
+ }
+ }
+ let mut found: Vec = seen
+ .into_values()
+ .map(|seen| {
+ let mut bots: Vec<_> = seen.bots.into_iter().collect();
+ bots.sort_by_key(|(_, at)| std::cmp::Reverse(*at));
+ let mut pasted = seen.pasted;
+ pasted.bots = bots
+ .into_iter()
+ .filter_map(|(bot, _)| bot.parse().ok())
+ .collect();
+ pasted
+ })
+ .collect();
+ found.sort_by(|a, b| {
+ (b.last_at, b.places)
+ .cmp(&(a.last_at, a.places))
+ .then(a.id.cmp(&b.id))
+ });
+ Ok(found)
+ }
+
+ /// Swaps `value` for `with` in every text, at once; how many changed.
+ pub fn replace_pasted(&self, value: &str, with: &str) -> Result {
+ let tx = self.conn.unchecked_transaction()?;
+ let mut changed = 0;
+ for (table, _, column, _) in PLACES {
+ changed += tx.execute(
+ &format!(
+ "UPDATE {table} SET {column} = replace({column}, ?1, ?2) \
+ WHERE instr({column}, ?1) > 0"
+ ),
+ params![value, with],
+ )?;
+ }
+ tx.commit()?;
+ Ok(u32::try_from(changed).unwrap_or(u32::MAX))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use botloft_core::ids::ChatItemId;
+ use botloft_core::protocol::{ChatBody, ChatItem, ReplyItem};
+
+ use crate::search::SearchFilter;
+ use crate::tests::Fixture;
+
+ const GITHUB: &str = "ghp_abcdefghijklmnopqrstuvwxyz0123";
+ // In two parts, so the push protection of a code host does not take it
+ // for a real token.
+ const SLACK: &str = concat!("xox", "b-1234567890-abcdefghijklmnop");
+
+ fn reply(fx: &Fixture, bot: usize, text: &str, at: i64) -> ChatItemId {
+ let item = ChatItem {
+ id: ChatItemId::generate(),
+ bot_id: fx.bots[bot].id.clone(),
+ body: ChatBody::Reply(ReplyItem {
+ text: text.to_owned(),
+ }),
+ created_at: at,
+ updated_at: at,
+ };
+ fx.store.insert_chat_item(&item).expect("item");
+ item.id
+ }
+
+ #[test]
+ fn values_are_found_once_with_their_agents_and_swapped_everywhere() {
+ let fx = Fixture::new();
+ reply(
+ &fx,
+ 0,
+ &format!("Your token is {GITHUB}\nand again {GITHUB}"),
+ 10,
+ );
+ reply(&fx, 1, &format!("I used {GITHUB} and {SLACK}"), 30);
+ reply(&fx, 1, "nothing here, just sk-learn and a task-runner", 40);
+
+ let found = fx.store.pasted_keys().expect("scan");
+ let seen: Vec<_> = found
+ .iter()
+ .map(|p| (p.value.as_str(), p.places, p.last_at))
+ .collect();
+ assert_eq!(seen, [(GITHUB, 2, 30), (SLACK, 1, 30)]);
+ assert_eq!(
+ found[0].bots,
+ [fx.bots[1].id.clone(), fx.bots[0].id.clone()]
+ );
+ assert_eq!(found[0].preview, "ghp_…0123");
+ assert_eq!(found[0].id, super::pasted_id(GITHUB));
+
+ let hits = |word: &str| {
+ let query = crate::fts_query(word).expect("query");
+ let filter = SearchFilter {
+ bot: None,
+ crew: None,
+ before: None,
+ limit: 50,
+ };
+ fx.store.search_chat(&query, filter).expect("search").len()
+ };
+ assert_eq!(hits(GITHUB), 2);
+ assert_eq!(
+ fx.store
+ .replace_pasted(GITHUB, "[chave GITHUB_TOKEN]")
+ .expect("replace"),
+ 2
+ );
+ assert_eq!(hits(GITHUB), 0);
+ assert_eq!(hits("GITHUB_TOKEN"), 2);
+ let left = fx.store.pasted_keys().expect("again");
+ assert_eq!(left.len(), 1);
+ assert_eq!(left[0].value, SLACK);
+ }
+}
diff --git a/crates/botloft-store/src/lib.rs b/crates/botloft-store/src/lib.rs
index 878948a8..08e43664 100644
--- a/crates/botloft-store/src/lib.rs
+++ b/crates/botloft-store/src/lib.rs
@@ -4,6 +4,7 @@
//! One [`Store`] wraps one connection. The daemon keeps a single store behind
//! a mutex; queries are short, so there is no connection pool.
+mod agent_keys;
mod allow_rules;
mod approvals;
mod bots;
@@ -15,6 +16,7 @@ mod delete;
mod deliveries;
mod desktop_grants;
mod fingerprint;
+mod key_scan;
mod light;
mod mcp_servers;
mod messages;
@@ -43,6 +45,7 @@ pub use approvals::ApprovalRecord;
pub use bots::BotRecord;
pub use crew_access::{AccessKinds, CrewAccessRecord};
pub use deliveries::DeliveryOutcome;
+pub use key_scan::{Pasted, pasted_id};
pub use messages::MessageFilter;
pub use migrate::LATEST_VERSION;
pub use plan::PlanReading;
diff --git a/crates/botloft-store/src/migrate.rs b/crates/botloft-store/src/migrate.rs
index ef713dd0..b25281da 100644
--- a/crates/botloft-store/src/migrate.rs
+++ b/crates/botloft-store/src/migrate.rs
@@ -41,6 +41,7 @@ const MIGRATIONS: &[(u32, &str)] = &[
(29, include_str!("../migrations/0029_read_through.sql")),
(30, include_str!("../migrations/0030_crew_auto_mode.sql")),
(31, include_str!("../migrations/0031_bot_plan_first.sql")),
+ (32, include_str!("../migrations/0032_agent_keys.sql")),
];
/// Schema version after every migration has run.
diff --git a/crates/botloftd/src/agent/mod.rs b/crates/botloftd/src/agent/mod.rs
index 5a73d650..e8d3f3c2 100644
--- a/crates/botloftd/src/agent/mod.rs
+++ b/crates/botloftd/src/agent/mod.rs
@@ -180,8 +180,13 @@ pub(crate) fn neutral_turn_with_images(turn: &Turn) -> Bytes {
/// The rules `prepare_bot` wrote for Claude Code are the bot's rules; the
/// other agents read `AGENTS.md` from the bot's folder.
pub(crate) fn copy_rules_to_agents_md(workspace: &Path) -> io::Result<()> {
- let rules = workspace.join(".claude").join("rules").join("botloft.md");
- if let Ok(text) = std::fs::read_to_string(rules) {
+ let rules = workspace.join(".claude").join("rules");
+ if let Ok(mut text) = std::fs::read_to_string(rules.join("botloft.md")) {
+ // The agent's keys, when it has any (spec 32.4).
+ if let Ok(keys) = std::fs::read_to_string(rules.join("keys.md")) {
+ text.push('\n');
+ text.push_str(&keys);
+ }
std::fs::write(workspace.join("AGENTS.md"), text)?;
}
Ok(())
diff --git a/crates/botloftd/src/agent_keys.rs b/crates/botloftd/src/agent_keys.rs
new file mode 100644
index 00000000..c748b0d1
--- /dev/null
+++ b/crates/botloftd/src/agent_keys.rs
@@ -0,0 +1,260 @@
+//! An agent's keys (spec 32): the values live in `secrets\keys\.json`,
+//! readable only by the current user, and reach the agent only as variables
+//! of its process. Every text the daemon keeps about the agent has those
+//! values replaced by `[chave NAME]` first. Values never go to the
+//! database, the log or the app.
+
+use std::collections::{BTreeMap, HashMap};
+use std::ffi::OsString;
+use std::io;
+use std::path::{Path, PathBuf};
+use std::sync::{Arc, Mutex, MutexGuard};
+
+use botloft_core::ids::BotId;
+use botloft_core::keys::redact;
+use botloft_core::protocol::ChatBody;
+use tracing::warn;
+
+use crate::platform;
+use crate::state::Daemon;
+
+/// A key's name and value.
+pub type Pairs = Vec<(String, String)>;
+
+fn folder(secrets_dir: &Path) -> PathBuf {
+ secrets_dir.join("keys")
+}
+
+fn file(secrets_dir: &Path, bot: &BotId) -> PathBuf {
+ folder(secrets_dir).join(format!("{bot}.json"))
+}
+
+fn read(secrets_dir: &Path, bot: &BotId) -> io::Result> {
+ match std::fs::read(file(secrets_dir, bot)) {
+ Ok(bytes) => serde_json::from_slice(&bytes).map_err(io::Error::other),
+ Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(BTreeMap::new()),
+ Err(err) => Err(err),
+ }
+}
+
+fn write(secrets_dir: &Path, bot: &BotId, keys: &BTreeMap) -> io::Result<()> {
+ let path = file(secrets_dir, bot);
+ if keys.is_empty() {
+ return match std::fs::remove_file(&path) {
+ Err(err) if err.kind() != io::ErrorKind::NotFound => Err(err),
+ _ => Ok(()),
+ };
+ }
+ let folder = folder(secrets_dir);
+ std::fs::create_dir_all(&folder)?;
+ platform::restrict_to_current_user(&folder)?;
+ let tmp = path.with_extension("tmp");
+ std::fs::write(&tmp, serde_json::to_vec(keys).map_err(io::Error::other)?)?;
+ platform::restrict_to_current_user(&tmp)?;
+ std::fs::rename(&tmp, &path)
+}
+
+/// The keys of every agent, read once from their files and kept in memory,
+/// since every chat item looks them up.
+#[derive(Default)]
+pub struct KeyVault {
+ cache: Mutex>>,
+}
+
+impl KeyVault {
+ fn lock(&self) -> MutexGuard<'_, HashMap>> {
+ self.cache
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ }
+
+ /// The agent's keys; none when its file cannot be read.
+ pub fn pairs(&self, secrets_dir: &Path, bot: &BotId) -> Arc {
+ if let Some(pairs) = self.lock().get(bot) {
+ return Arc::clone(pairs);
+ }
+ let pairs = match read(secrets_dir, bot) {
+ Ok(keys) => Arc::new(keys.into_iter().collect::()),
+ Err(err) => {
+ warn!(bot = %bot, "could not read the agent's keys: {err}");
+ Arc::new(Pairs::new())
+ }
+ };
+ self.lock().insert(bot.clone(), Arc::clone(&pairs));
+ pairs
+ }
+
+ /// Saves `value` under `name` for the agent.
+ pub fn set(&self, secrets_dir: &Path, bot: &BotId, name: &str, value: &str) -> io::Result<()> {
+ let mut keys = read(secrets_dir, bot)?;
+ keys.insert(name.to_owned(), value.to_owned());
+ write(secrets_dir, bot, &keys)?;
+ self.lock().remove(bot);
+ Ok(())
+ }
+
+ /// Forgets one key of the agent, or all of them with `None`.
+ pub fn remove(&self, secrets_dir: &Path, bot: &BotId, name: Option<&str>) -> io::Result<()> {
+ let mut keys = match name {
+ Some(_) => read(secrets_dir, bot)?,
+ None => BTreeMap::new(),
+ };
+ if let Some(name) = name {
+ keys.remove(name);
+ }
+ write(secrets_dir, bot, &keys)?;
+ self.lock().remove(bot);
+ Ok(())
+ }
+}
+
+/// The variables the agent starts with (spec 32.4).
+pub fn environment(daemon: &Daemon, bot: &BotId) -> Vec<(OsString, OsString)> {
+ daemon
+ .keys
+ .pairs(&daemon.paths.secrets(), bot)
+ .iter()
+ .map(|(name, value)| (OsString::from(name), OsString::from(value)))
+ .collect()
+}
+
+/// Replaces the agent's key values in every text of `body` (spec 32.6).
+pub fn redact_body(daemon: &Daemon, bot: &BotId, body: &mut ChatBody) {
+ let pairs = daemon.keys.pairs(&daemon.paths.secrets(), bot);
+ if !pairs.is_empty() {
+ redact_with(&pairs, body);
+ }
+}
+
+/// `text` with the agent's key values replaced.
+pub fn redact_text(daemon: &Daemon, bot: &BotId, text: &mut String) {
+ let pairs = daemon.keys.pairs(&daemon.paths.secrets(), bot);
+ if let Some(clean) = redact(text, &pairs) {
+ *text = clean;
+ }
+}
+
+fn redact_with(pairs: &Pairs, body: &mut ChatBody) {
+ let clean = |text: &mut String| {
+ if let Some(out) = redact(text, pairs) {
+ *text = out;
+ }
+ };
+ let clean_opt = |text: &mut Option| {
+ if let Some(text) = text
+ && let Some(out) = redact(text, pairs)
+ {
+ *text = out;
+ }
+ };
+ match body {
+ ChatBody::Reply(reply) => clean(&mut reply.text),
+ ChatBody::Tool(tool) => {
+ clean(&mut tool.summary);
+ clean(&mut tool.input);
+ clean_opt(&mut tool.explanation);
+ clean_opt(&mut tool.output);
+ }
+ ChatBody::Approval(approval) => {
+ clean(&mut approval.summary);
+ clean(&mut approval.input);
+ clean_opt(&mut approval.explanation);
+ }
+ ChatBody::Inbound(inbound) => clean(&mut inbound.message.body),
+ ChatBody::Notice(notice) => clean(&mut notice.text),
+ ChatBody::KeyRequest(request) => clean(&mut request.why),
+ ChatBody::Question(_) | ChatBody::Turn(_) => {}
+ }
+}
+
+/// Writes with the agent's keys, or removes it
+/// without any (spec 32.4). Only the names and descriptions: never a value.
+pub fn write_rules(workspace: &Path, keys: &[botloft_core::protocol::AgentKey]) -> io::Result<()> {
+ let path = workspace.join(".claude").join("rules").join("keys.md");
+ let present: Vec<_> = keys.iter().filter(|key| !key.missing).collect();
+ if present.is_empty() {
+ return match std::fs::remove_file(&path) {
+ Err(err) if err.kind() != io::ErrorKind::NotFound => Err(err),
+ _ => Ok(()),
+ };
+ }
+ let mut text = String::from(
+ "# Your keys\n\nThe owner saved these keys for you. Each one is in your environment \
+ under its name: use it by the name (`$env:NAME` in PowerShell, `$NAME` in Bash), \
+ never by its value.\n\n",
+ );
+ for key in present {
+ text.push_str(&format!("- `{}`", key.name));
+ if !key.description.is_empty() {
+ text.push_str(&format!(": {}", key.description));
+ }
+ text.push('\n');
+ }
+ text.push_str(
+ "\nNever write a key's value in a reply, a file, a message to another bot, or a \
+ command that prints it (echo, Write-Output). If a key is missing or does not work, \
+ ask the owner for it with ask_key, by its name.\n",
+ );
+ std::fs::create_dir_all(path.parent().unwrap_or(workspace))?;
+ std::fs::write(path, text)
+}
+
+#[cfg(test)]
+mod tests {
+ use botloft_core::protocol::{ReplyItem, ToolItem, ToolStatus};
+
+ use super::*;
+
+ #[test]
+ fn keys_are_saved_per_agent_and_forgotten() {
+ let dir = tempfile::tempdir().expect("dir");
+ let vault = KeyVault::default();
+ let (a, b) = (BotId::generate(), BotId::generate());
+ vault
+ .set(dir.path(), &a, "WA_TOKEN", "wa-1234567890")
+ .expect("set");
+ vault
+ .set(dir.path(), &a, "B_KEY", "b-1234567890")
+ .expect("set");
+ assert_eq!(vault.pairs(dir.path(), &a).len(), 2);
+ assert!(vault.pairs(dir.path(), &b).is_empty());
+ vault.remove(dir.path(), &a, Some("B_KEY")).expect("remove");
+ assert_eq!(
+ *vault.pairs(dir.path(), &a),
+ vec![("WA_TOKEN".to_owned(), "wa-1234567890".to_owned())]
+ );
+ vault.remove(dir.path(), &a, None).expect("all");
+ assert!(vault.pairs(dir.path(), &a).is_empty());
+ assert!(!file(dir.path(), &a).exists(), "no file is left");
+ }
+
+ #[test]
+ fn every_text_of_an_item_loses_the_values() {
+ let pairs = vec![("WA_TOKEN".to_owned(), "wa-1234567890".to_owned())];
+ let mut tool = ChatBody::Tool(ToolItem {
+ tool_use_id: "toolu_1".into(),
+ name: "Bash".into(),
+ summary: "echo wa-1234567890".into(),
+ explanation: Some("prints wa-1234567890".into()),
+ input: r#"{"command":"echo wa-1234567890"}"#.into(),
+ status: ToolStatus::Done,
+ output: Some("wa-1234567890".into()),
+ file: None,
+ lines: None,
+ });
+ redact_with(&pairs, &mut tool);
+ let text = serde_json::to_string(&tool).expect("json");
+ assert!(!text.contains("wa-1234567890"), "{text}");
+ assert!(text.contains("[chave WA_TOKEN]"), "{text}");
+ let mut reply = ChatBody::Reply(ReplyItem {
+ text: "the token is wa-1234567890".into(),
+ });
+ redact_with(&pairs, &mut reply);
+ assert_eq!(
+ reply,
+ ChatBody::Reply(ReplyItem {
+ text: "the token is [chave WA_TOKEN]".into()
+ })
+ );
+ }
+}
diff --git a/crates/botloftd/src/approvals/mod.rs b/crates/botloftd/src/approvals/mod.rs
index 64cf1d0a..dad9dffc 100644
--- a/crates/botloftd/src/approvals/mod.rs
+++ b/crates/botloftd/src/approvals/mod.rs
@@ -322,10 +322,12 @@ fn open(
tool_explanation(tool_name, input),
always::scope_of(tool_name, input),
);
+ let mut body = ChatBody::Approval(shown);
+ crate::agent_keys::redact_body(daemon, bot, &mut body);
let item = ChatItem {
id: ChatItemId::generate(),
bot_id: bot.clone(),
- body: ChatBody::Approval(shown),
+ body,
created_at: now,
updated_at: now,
};
@@ -406,7 +408,12 @@ fn show(daemon: &Daemon, record: &ApprovalRecord) {
_ => (None, None),
};
let shown = item_of(&record.approval, explanation, always);
- items::update(daemon, item, ChatBody::Approval(shown));
+ items::update(
+ daemon,
+ &record.approval.bot_id,
+ item,
+ ChatBody::Approval(shown),
+ );
}
/// Expires the request if the MCP call goes away before it is settled,
diff --git a/crates/botloftd/src/chat/items.rs b/crates/botloftd/src/chat/items.rs
index 32502a29..0d86182f 100644
--- a/crates/botloftd/src/chat/items.rs
+++ b/crates/botloftd/src/chat/items.rs
@@ -9,7 +9,9 @@ use crate::state::{Daemon, Event};
/// Saves a new item for `bot` and announces it with the bot's new
/// conversation-list line.
-pub(crate) fn add(daemon: &Daemon, bot: &BotId, body: ChatBody) -> Option {
+pub(crate) fn add(daemon: &Daemon, bot: &BotId, mut body: ChatBody) -> Option {
+ // An agent's key never gets kept as it is (spec 32.6).
+ crate::agent_keys::redact_body(daemon, bot, &mut body);
let now = daemon.clock.now_ms();
let item = ChatItem {
id: ChatItemId::generate(),
@@ -42,7 +44,13 @@ pub(crate) fn changed(daemon: &Daemon, item: ChatItem) {
}
/// Replaces an item's body, e.g. a tool that finished, and announces it.
-pub(crate) fn update(daemon: &Daemon, id: &ChatItemId, body: ChatBody) -> Option {
+pub(crate) fn update(
+ daemon: &Daemon,
+ bot: &BotId,
+ id: &ChatItemId,
+ mut body: ChatBody,
+) -> Option {
+ crate::agent_keys::redact_body(daemon, bot, &mut body);
let now = daemon.clock.now_ms();
match daemon.store().update_chat_item(id, &body, now) {
Ok(Some(item)) => {
diff --git a/crates/botloftd/src/chat/sink.rs b/crates/botloftd/src/chat/sink.rs
index 9018a879..d6609d13 100644
--- a/crates/botloftd/src/chat/sink.rs
+++ b/crates/botloftd/src/chat/sink.rs
@@ -68,7 +68,7 @@ pub(crate) fn tool_finished(daemon: &Daemon, bot: &BotId, id: &str, failed: bool
output: (!output.is_empty()).then(|| clip(output, TOOL_OUTPUT_MAX)),
..tool
});
- items::update(daemon, &item.id, body);
+ items::update(daemon, bot, &item.id, body);
}
/// The line that closes a turn: how long it took, its tokens when the agent
diff --git a/crates/botloftd/src/lib.rs b/crates/botloftd/src/lib.rs
index c352008e..a89b27bf 100644
--- a/crates/botloftd/src/lib.rs
+++ b/crates/botloftd/src/lib.rs
@@ -3,6 +3,7 @@
//! parses the command line and wires these modules together.
pub mod agent;
+pub mod agent_keys;
pub mod approvals;
pub mod autobackup;
pub mod autostart;
diff --git a/crates/botloftd/src/mobile/chats.rs b/crates/botloftd/src/mobile/chats.rs
index be56c864..8207436c 100644
--- a/crates/botloftd/src/mobile/chats.rs
+++ b/crates/botloftd/src/mobile/chats.rs
@@ -140,6 +140,8 @@ pub fn item(daemon: &Daemon, item: &ChatItem) -> Option {
text: cut(&shown.question.text).0,
status: shown.question.status,
},
+ // Pasting a key is for the computer (spec 32.5).
+ ChatBody::KeyRequest(_) => return None,
ChatBody::Turn(turn) => {
let error = turn.error.as_deref()?;
PhoneItem::Failed {
diff --git a/crates/botloftd/src/rpc/dispatch.rs b/crates/botloftd/src/rpc/dispatch.rs
index ddf789cd..0100d45e 100644
--- a/crates/botloftd/src/rpc/dispatch.rs
+++ b/crates/botloftd/src/rpc/dispatch.rs
@@ -9,7 +9,7 @@ use super::jsonrpc::{RpcError, empty_params};
use crate::approvals;
use crate::service::{
self, ApiError, ApiResult, agents, archive, attachments, autobackup, backup, bots, catalog,
- chat, cloud, crew_access, crews, delete, deliveries, desktop, files, island, lead, mcp,
+ chat, cloud, crew_access, crews, delete, deliveries, desktop, files, island, keys, lead, mcp,
messages, mobile, models, modes, questions, reactions, routines, rules, screens, settings,
tasks, usage,
};
@@ -131,6 +131,14 @@ pub fn dispatch(daemon: &Daemon, name: &str, params: Option) -> Result reply(Ok(daemon.browsers.list())),
method::CARDS_LIST => reply(Ok::<_, ApiError>(island::cards(daemon))),
method::ACTIVITY_TODAY => reply(island::today(daemon, parse(params)?)),
+ method::KEYS_LIST => reply(keys::list(daemon, parse(params)?)),
+ method::KEYS_SET => reply(keys::set(daemon, parse(params)?)),
+ method::KEYS_DELETE => reply(keys::delete(daemon, parse(params)?)),
+ method::KEYS_DISMISS => {
+ reply(service::key_requests::dismiss(daemon, parse(params)?).map(|()| Value::Null))
+ }
+ method::KEYS_SCAN => reply(service::key_scan::scan(daemon)),
+ method::KEYS_SCRUB => reply(service::key_scan::scrub(daemon, parse(params)?)),
method::QUESTIONS_LIST => reply(questions::list(daemon, parse(params)?)),
method::QUESTIONS_ANSWER => reply(questions::answer(daemon, parse(params)?)),
method::QUESTIONS_DISMISS => reply(questions::dismiss(daemon, parse(params)?)),
diff --git a/crates/botloftd/src/service/delete.rs b/crates/botloftd/src/service/delete.rs
index 80347f41..ed570afb 100644
--- a/crates/botloftd/src/service/delete.rs
+++ b/crates/botloftd/src/service/delete.rs
@@ -124,6 +124,10 @@ fn stop(daemon: &Daemon, bot: &BotId) {
fn release(daemon: &Daemon, bot: &BotId) {
daemon.routines.forget(bot);
daemon.screens.forget(bot);
+ // Its keys go with it (spec 32.2).
+ if let Err(err) = daemon.keys.remove(&daemon.paths.secrets(), bot, None) {
+ tracing::warn!(bot = %bot, "could not remove the agent's keys: {err}");
+ }
}
/// What the other bots should hear about the unfinished tasks a deleted
diff --git a/crates/botloftd/src/service/key_requests.rs b/crates/botloftd/src/service/key_requests.rs
new file mode 100644
index 00000000..62a21de7
--- /dev/null
+++ b/crates/botloftd/src/service/key_requests.rs
@@ -0,0 +1,153 @@
+//! A bot asks the owner for a key (spec 32.5): `ask_key` opens a card in its
+//! chat, which is the request itself; the owner saves the key with
+//! `keys.set` and the request's id, or says "not now" with `keys.dismiss`.
+//! Either way the bot hears it in a message from Botloft.
+
+use botloft_core::ids::{BotId, ChatItemId, MessageId};
+use botloft_core::keys;
+use botloft_core::protocol::{
+ ChatBody, KeyRequestItem, KeyRequestStatus, KeysDismissParams, Message, MessageKind, SenderKind,
+};
+use tracing::warn;
+
+use super::{ApiError, ApiResult, bots, messages};
+use crate::chat::items;
+use crate::state::Daemon;
+
+/// Requests a bot may leave open at once.
+pub const OPEN_KEY_REQUESTS_MAX: u32 = 5;
+/// Longest reason, in characters.
+pub const WHY_MAX_CHARS: usize = 500;
+
+/// Checks the request and shows it in the bot's chat; the item's id is the
+/// request's.
+pub fn ask(daemon: &Daemon, bot: &BotId, name: &str, why: &str) -> ApiResult {
+ let name = keys::name(name)?;
+ let why = why.trim();
+ if why.is_empty() || why.chars().count() > WHY_MAX_CHARS {
+ return Err(ApiError::validation(format!(
+ "why must say in at most {WHY_MAX_CHARS} characters what you need the key for"
+ )));
+ }
+ {
+ let store = daemon.store();
+ bots::active(&store, bot)?;
+ if store.open_key_requests(bot)? >= OPEN_KEY_REQUESTS_MAX {
+ return Err(ApiError::Conflict(format!(
+ "you already have {OPEN_KEY_REQUESTS_MAX} requests for keys the owner has not \
+ answered; wait for an answer before asking again"
+ )));
+ }
+ }
+ let item = items::add(
+ daemon,
+ bot,
+ ChatBody::KeyRequest(KeyRequestItem {
+ name,
+ why: why.to_owned(),
+ status: KeyRequestStatus::Open,
+ }),
+ )
+ .ok_or_else(|| ApiError::Conflict("could not save the request".to_owned()))?;
+ Ok(item.id)
+}
+
+/// The open request `id` of `bot`, if it asks for `name`.
+pub fn open(
+ daemon: &Daemon,
+ bot: &BotId,
+ id: &ChatItemId,
+ name: &str,
+) -> ApiResult {
+ let item = daemon
+ .store()
+ .chat_item(id)?
+ .filter(|item| &item.bot_id == bot)
+ .ok_or_else(|| ApiError::NotFound(format!("request {id} does not exist")))?;
+ let ChatBody::KeyRequest(request) = item.body else {
+ return Err(ApiError::NotFound(format!("request {id} does not exist")));
+ };
+ if request.status != KeyRequestStatus::Open {
+ return Err(ApiError::Conflict(format!(
+ "request {id} was already answered"
+ )));
+ }
+ if request.name != name {
+ return Err(ApiError::validation(format!(
+ "the request asks for {}, not {name}",
+ request.name
+ )));
+ }
+ Ok(request)
+}
+
+/// Closes the request as `status` and tells the bot `body`.
+pub fn close(
+ daemon: &Daemon,
+ bot: &BotId,
+ id: &ChatItemId,
+ mut request: KeyRequestItem,
+ status: KeyRequestStatus,
+ body: String,
+) -> ApiResult<()> {
+ request.status = status;
+ items::update(daemon, bot, id, ChatBody::KeyRequest(request));
+ let store = daemon.store();
+ let (crew, _) = bots::active(&store, bot)?;
+ let note = Message {
+ id: MessageId::generate(),
+ crew_id: crew.id,
+ from_kind: SenderKind::System,
+ from_bot_id: None,
+ to_bot_id: bot.clone(),
+ kind: MessageKind::System,
+ body,
+ task_id: None,
+ routine_id: None,
+ question_id: None,
+ reply_to: None,
+ attachments: Vec::new(),
+ created_at: daemon.clock.now_ms(),
+ };
+ if let Err(err) = messages::post(daemon, &store, note, None) {
+ warn!(bot = %bot, "could not tell a bot about its key: {err}");
+ }
+ Ok(())
+}
+
+/// What the bot hears once its key is saved.
+pub fn saved_note(name: &str, replaced: bool) -> String {
+ let what = if replaced { "replaced" } else { "saved" };
+ format!(
+ "The owner {what} the key {name} you asked for. It is in your environment as {name} \
+ ($env:{name} in PowerShell, ${name} in Bash): use it by that name, never by its value."
+ )
+}
+
+pub fn dismiss(daemon: &Daemon, params: KeysDismissParams) -> ApiResult<()> {
+ let item = daemon
+ .store()
+ .chat_item(¶ms.request_id)?
+ .ok_or_else(|| {
+ ApiError::NotFound(format!("request {} does not exist", params.request_id))
+ })?;
+ let ChatBody::KeyRequest(request) = &item.body else {
+ return Err(ApiError::NotFound(format!(
+ "request {} does not exist",
+ params.request_id
+ )));
+ };
+ let request = open(daemon, &item.bot_id, &item.id, &request.name)?;
+ let body = format!(
+ "The owner did not give the key {} now. Carry on without it, or ask again later.",
+ request.name
+ );
+ close(
+ daemon,
+ &item.bot_id,
+ &item.id,
+ request,
+ KeyRequestStatus::Dismissed,
+ body,
+ )
+}
diff --git a/crates/botloftd/src/service/key_scan.rs b/crates/botloftd/src/service/key_scan.rs
new file mode 100644
index 00000000..9f58e4fd
--- /dev/null
+++ b/crates/botloftd/src/service/key_scan.rs
@@ -0,0 +1,58 @@
+//! `keys.scan` and `keys.scrub` (spec 32.7): keys pasted into the
+//! conversations before the owner kept them apart. A hit goes out with only
+//! its start and end; scrubbing finds the value again by the hit's id.
+
+use botloft_core::protocol::{KeyHit, KeysScrubParams, KeysSetParams};
+use botloft_store::Pasted;
+use tracing::info;
+
+use super::{ApiError, ApiResult, keys};
+use crate::state::Daemon;
+
+/// What a deleted key reads as from then on.
+pub const REMOVED: &str = "[chave removida]";
+
+fn hit(pasted: Pasted) -> KeyHit {
+ KeyHit {
+ id: pasted.id,
+ preview: pasted.preview,
+ bot_ids: pasted.bots,
+ places: pasted.places,
+ last_at: pasted.last_at,
+ }
+}
+
+pub fn scan(daemon: &Daemon) -> ApiResult> {
+ Ok(daemon.store().pasted_keys()?.into_iter().map(hit).collect())
+}
+
+/// Keeps or deletes each hit, then scans again.
+pub fn scrub(daemon: &Daemon, params: KeysScrubParams) -> ApiResult> {
+ let found = daemon.store().pasted_keys()?;
+ for scrub in params.hits {
+ let pasted = found
+ .iter()
+ .find(|pasted| pasted.id == scrub.id)
+ .ok_or_else(|| ApiError::NotFound(format!("hit {} is gone", scrub.id)))?;
+ let mark = match scrub.keep {
+ Some(keep) => {
+ // Saved first: a name the daemon refuses leaves the text as it was.
+ keys::set(
+ daemon,
+ KeysSetParams {
+ bot_id: keep.bot_id,
+ name: keep.name.clone(),
+ value: pasted.value.clone(),
+ description: None,
+ request_id: None,
+ },
+ )?;
+ format!("[chave {}]", keep.name.trim())
+ }
+ None => REMOVED.to_owned(),
+ };
+ let changed = daemon.store().replace_pasted(&pasted.value, &mark)?;
+ info!(hit = %scrub.id, texts = changed, "pasted key scrubbed");
+ }
+ scan(daemon)
+}
diff --git a/crates/botloftd/src/service/keys.rs b/crates/botloftd/src/service/keys.rs
new file mode 100644
index 00000000..1230a225
--- /dev/null
+++ b/crates/botloftd/src/service/keys.rs
@@ -0,0 +1,94 @@
+//! `keys.list`, `keys.set` and `keys.delete` (spec 32): the owner keeps an
+//! agent's keys out of the chat. A value goes in and never comes back out;
+//! saving or removing one restarts the agent, resuming its conversation,
+//! once nothing is in progress.
+
+use botloft_core::ids::BotId;
+use botloft_core::keys;
+use botloft_core::protocol::{
+ AgentKey, KeyRequestStatus, KeysDeleteParams, KeysListParams, KeysSetParams,
+};
+use tracing::info;
+
+use super::{ApiError, ApiResult, key_requests};
+use crate::state::Daemon;
+
+fn known(daemon: &Daemon, bot: &BotId) -> ApiResult<()> {
+ match daemon.store().bot(bot)? {
+ Some(_) => Ok(()),
+ None => Err(ApiError::NotFound(format!("bot {bot} does not exist"))),
+ }
+}
+
+/// The agent's keys, with the ones whose value is not on this computer
+/// marked `missing` (spec 32.2).
+pub fn listed(daemon: &Daemon, bot: &BotId) -> ApiResult> {
+ let mut keys = daemon.store().agent_keys(bot)?;
+ let values = daemon.keys.pairs(&daemon.paths.secrets(), bot);
+ for key in &mut keys {
+ key.missing = !values.iter().any(|(name, _)| name == &key.name);
+ }
+ Ok(keys)
+}
+
+pub fn list(daemon: &Daemon, params: KeysListParams) -> ApiResult> {
+ known(daemon, ¶ms.bot_id)?;
+ listed(daemon, ¶ms.bot_id)
+}
+
+pub fn set(daemon: &Daemon, params: KeysSetParams) -> ApiResult> {
+ let bot = ¶ms.bot_id;
+ known(daemon, bot)?;
+ let name = keys::name(¶ms.name)?;
+ let value = keys::value(¶ms.value)?;
+ let description = params
+ .description
+ .as_deref()
+ .map(keys::description)
+ .transpose()?;
+ // Answering the bot's request: checked before anything is saved.
+ let request = params
+ .request_id
+ .as_ref()
+ .map(|id| key_requests::open(daemon, bot, id, &name).map(|request| (id, request)))
+ .transpose()?;
+ let replaced = daemon
+ .keys
+ .pairs(&daemon.paths.secrets(), bot)
+ .iter()
+ .any(|(have, _)| have == &name);
+ daemon
+ .keys
+ .set(&daemon.paths.secrets(), bot, &name, &value)
+ .map_err(ApiError::Workspace)?;
+ let now = daemon.clock.now_ms();
+ daemon
+ .store()
+ .set_agent_key(bot, &name, description.as_deref(), now)?;
+ info!(bot = %bot, key = %name, "agent key saved");
+ // Restarted first, so the note reaches a process that has the key.
+ daemon.supervisor.launch_settings_changed(bot);
+ if let Some((id, request)) = request {
+ let note = key_requests::saved_note(&name, replaced);
+ key_requests::close(daemon, bot, id, request, KeyRequestStatus::Saved, note)?;
+ }
+ listed(daemon, bot)
+}
+
+pub fn delete(daemon: &Daemon, params: KeysDeleteParams) -> ApiResult> {
+ let bot = ¶ms.bot_id;
+ known(daemon, bot)?;
+ if !daemon.store().delete_agent_key(bot, ¶ms.name)? {
+ return Err(ApiError::NotFound(format!(
+ "key {} does not exist",
+ params.name
+ )));
+ }
+ daemon
+ .keys
+ .remove(&daemon.paths.secrets(), bot, Some(¶ms.name))
+ .map_err(ApiError::Workspace)?;
+ info!(bot = %bot, key = %params.name, "agent key removed");
+ daemon.supervisor.launch_settings_changed(bot);
+ listed(daemon, bot)
+}
diff --git a/crates/botloftd/src/service/messages.rs b/crates/botloftd/src/service/messages.rs
index eb3a3f47..1a9d8945 100644
--- a/crates/botloftd/src/service/messages.rs
+++ b/crates/botloftd/src/service/messages.rs
@@ -92,9 +92,15 @@ fn quote(store: &Store, bot: &BotId, item_id: &ChatItemId) -> ApiResult,
) -> ApiResult {
+ // Neither the sender's keys nor the receiver's are kept or passed on as
+ // they are (spec 32.6).
+ crate::agent_keys::redact_text(daemon, &message.to_bot_id, &mut message.body);
+ if let Some(from) = &message.from_bot_id {
+ crate::agent_keys::redact_text(daemon, from, &mut message.body);
+ }
let delivery = pending_delivery(&message);
let item = store.insert_message(&message, &delivery, task.as_ref())?;
let reactions = reactions::sent_with(store, &message)?;
diff --git a/crates/botloftd/src/service/mod.rs b/crates/botloftd/src/service/mod.rs
index 8b13f1e6..b66b86f0 100644
--- a/crates/botloftd/src/service/mod.rs
+++ b/crates/botloftd/src/service/mod.rs
@@ -17,6 +17,9 @@ pub mod deliveries;
pub mod desktop;
pub mod files;
pub mod island;
+pub mod key_requests;
+pub mod key_scan;
+pub mod keys;
pub mod lead;
pub mod mcp;
pub mod mcp_state;
diff --git a/crates/botloftd/src/state.rs b/crates/botloftd/src/state.rs
index 1eb766bd..7111eb80 100644
--- a/crates/botloftd/src/state.rs
+++ b/crates/botloftd/src/state.rs
@@ -143,6 +143,8 @@ pub struct Daemon {
pub contexts: Contexts,
/// How each bot's connected tools stand since it started (spec 25.5).
pub mcp_states: crate::service::mcp_state::McpStates,
+ /// The values of the agents' keys, read from their files (spec 32).
+ pub keys: crate::agent_keys::KeyVault,
/// Time for everything stored or compared with stored times.
pub clock: Arc,
store: Mutex,
@@ -193,6 +195,7 @@ impl Daemon {
trash: options.trash,
contexts: Contexts::default(),
mcp_states: Default::default(),
+ keys: Default::default(),
clock: options.clock,
paths: options.paths,
port: options.port,
diff --git a/crates/botloftd/src/supervisor/mod.rs b/crates/botloftd/src/supervisor/mod.rs
index 4cfa131c..4e6f7da5 100644
--- a/crates/botloftd/src/supervisor/mod.rs
+++ b/crates/botloftd/src/supervisor/mod.rs
@@ -230,7 +230,9 @@ impl Supervisor {
let (Some(running), Some(generation)) = (&slot.running, slot.generation) else {
return Err(NotRunning);
};
- if slot.stop.is_some() {
+ // New settings wait for the running turn to end: what comes now goes
+ // to the process that has them, like a key it was told about.
+ if slot.stop.is_some() || slot.restart_when_idle {
return Err(NotRunning);
}
running.control.write(line).map_err(|_| NotRunning)?;
diff --git a/crates/botloftd/src/supervisor/spawn.rs b/crates/botloftd/src/supervisor/spawn.rs
index a36f7f4d..dd2ef8ec 100644
--- a/crates/botloftd/src/supervisor/spawn.rs
+++ b/crates/botloftd/src/supervisor/spawn.rs
@@ -109,6 +109,9 @@ pub(super) fn launch_spec(
.bot_mcp_servers(&bot.id)
.map_err(io::Error::other)?;
let workspace = workspace::prepare_bot(daemon.workspace_env(), crew, &crews, bot, &servers)?;
+ // The names of its keys, for its rules (spec 32.4).
+ let keys = crate::service::keys::listed(daemon, &bot.id).map_err(io::Error::other)?;
+ crate::agent_keys::write_rules(&workspace, &keys)?;
let token = secrets::random_token()?;
let (session, resumed) = match session {
Some(session) => (session.to_owned(), true),
@@ -142,6 +145,12 @@ pub(super) fn launch_spec(
.to_ascii_uppercase()
.starts_with("BOTLOFT_")
});
+ // The agent's keys, before the Botloft's own variables, whose names a key
+ // cannot take (spec 32.3, 32.4).
+ for (name, value) in crate::agent_keys::environment(daemon, &bot.id) {
+ env.retain(|(have, _)| !have.eq_ignore_ascii_case(&name));
+ env.push((name, value));
+ }
for (name, value) in [
("BOTLOFT_BOT_ID", OsString::from(bot.id.as_str())),
("BOTLOFT_BOT_TOKEN", OsString::from(&token)),
diff --git a/crates/botloftd/src/tools/calls.rs b/crates/botloftd/src/tools/calls.rs
index bef4bb68..7c1d0ed3 100644
--- a/crates/botloftd/src/tools/calls.rs
+++ b/crates/botloftd/src/tools/calls.rs
@@ -16,6 +16,7 @@ use tracing::warn;
use super::Failure;
use super::catalog::{COMPLETE_TASK, CREW_ROSTER, MY_TASKS, SEND_MESSAGE};
+use super::key::{self, ASK_KEY};
use super::question::{self, ASK_OWNER};
use super::share::{self, SHARE_FILE};
use super::signal::{self, SEND_SIGNAL};
@@ -46,6 +47,7 @@ pub(super) fn call(daemon: &Daemon, bot: &BotId, params: &Value) -> Result parse(arguments).and_then(|args| my_tasks(daemon, bot, args)),
SHARE_FILE => parse(arguments).and_then(|args| share::share(daemon, bot, args)),
ASK_OWNER => parse(arguments).and_then(|args| question::ask(daemon, bot, args)),
+ ASK_KEY => parse(arguments).and_then(|args| key::ask(daemon, bot, args)),
SEND_SIGNAL => parse(arguments).and_then(|args| signal::send(daemon, bot, args)),
routine_change::MY_ROUTINES => routine_change::mine(daemon, bot),
activity::CREW_ACTIVITY => {
diff --git a/crates/botloftd/src/tools/catalog.rs b/crates/botloftd/src/tools/catalog.rs
index 9fe09281..51cb68cb 100644
--- a/crates/botloftd/src/tools/catalog.rs
+++ b/crates/botloftd/src/tools/catalog.rs
@@ -27,6 +27,7 @@ pub fn tools() -> Value {
let more = [
super::share::tool(),
super::question::tool(),
+ super::key::tool(),
super::plan::tool(),
super::signal::tool(),
]
diff --git a/crates/botloftd/src/tools/key.rs b/crates/botloftd/src/tools/key.rs
new file mode 100644
index 00000000..7cd5daa6
--- /dev/null
+++ b/crates/botloftd/src/tools/key.rs
@@ -0,0 +1,69 @@
+//! `ask_key` (spec 32.5): the bot asks the owner for a key, such as an API
+//! token, and carries on. The owner types it in a form Botloft keeps out of
+//! the chat; the bot hears later, in a message, that it is in its environment.
+
+use botloft_core::ids::BotId;
+use botloft_core::keys::KEY_NAME_MAX;
+use serde::Deserialize;
+use serde_json::{Value, json};
+
+use super::calls::explain;
+use crate::service::key_requests::{self, WHY_MAX_CHARS};
+use crate::state::Daemon;
+
+pub const ASK_KEY: &str = "ask_key";
+
+pub(super) fn tool() -> Value {
+ json!({
+ "name": ASK_KEY,
+ "title": "Ask the owner for a key",
+ "description": "Asks the owner for a key you need, such as an API token or a password \
+ for a service, without it ever going through the chat. The owner types it in a \
+ form Botloft keeps apart; once saved, you restart with it as an environment \
+ variable by the name you asked for, and a message from Botloft tells you so. It \
+ does not wait: carry on with what does not need the key, or end your turn. Use it \
+ instead of asking for a key in your reply, and never ask the owner to paste a key \
+ in the chat.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "pattern": "^[A-Z][A-Z0-9_]*$",
+ "maxLength": KEY_NAME_MAX,
+ "description": "The environment variable you will read it from, in \
+ capitals, such as GITHUB_TOKEN or STRIPE_API_KEY.",
+ },
+ "why": {
+ "type": "string",
+ "maxLength": WHY_MAX_CHARS,
+ "description": "What you need it for and where the owner finds it, short, \
+ in the language the owner writes to you in.",
+ },
+ },
+ "required": ["name", "why"],
+ "additionalProperties": false,
+ },
+ })
+}
+
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(super) struct KeyArgs {
+ name: String,
+ why: String,
+}
+
+pub(super) fn ask(daemon: &Daemon, bot: &BotId, args: KeyArgs) -> Result {
+ let id = key_requests::ask(daemon, bot, &args.name, &args.why).map_err(explain)?;
+ Ok(json!({
+ "request_id": id,
+ "note": format!(
+ "The owner sees your request for {} in your chat. When they save the key you \
+ restart with it and a message from Botloft tells you; if they say not now, a \
+ message tells you that too. Don't wait for it: carry on with what doesn't need \
+ the key, or end your turn.",
+ args.name.trim()
+ ),
+ }))
+}
diff --git a/crates/botloftd/src/tools/mod.rs b/crates/botloftd/src/tools/mod.rs
index d91f20d8..45c62d4c 100644
--- a/crates/botloftd/src/tools/mod.rs
+++ b/crates/botloftd/src/tools/mod.rs
@@ -26,6 +26,7 @@ mod desktop_list;
mod desktop_real;
mod desktop_screen;
mod era;
+mod key;
mod plan;
mod question;
mod routine;
@@ -61,7 +62,7 @@ const CACHE_TTL_MS: u64 = 60 * 60 * 1000;
const INSTRUCTIONS: &str = "Tools to work with your Botloft crew: see who is in it, send notes \
or tasks to other bots, report the result of tasks assigned to you and, for the crew's chief, \
- suggest new bots (list_bot_templates and get_bot_template show the catalog of ready-made roles) and, to write the owner a summary, read what the crew's bots did with crew_activity. schedule_routine asks the owner for work at set times. ask_owner asks the owner a question without waiting; the answer arrives later as a message. show_plan shows the owner your plan before long work, when your rules ask for it. send_signal tells the crew something happened, and the routines waiting for it run. share_file shows the \
+ suggest new bots (list_bot_templates and get_bot_template show the catalog of ready-made roles) and, to write the owner a summary, read what the crew's bots did with crew_activity. schedule_routine asks the owner for work at set times. ask_owner asks the owner a question without waiting; the answer arrives later as a message. ask_key asks the owner for a key, such as an API token, without it going through the chat. show_plan shows the owner your plan before long work, when your rules ask for it. send_signal tells the crew something happened, and the routines waiting for it run. share_file shows the \
owner files you made, as cards in the chat they can open and save. The browser_ tools drive your own web browser, which the owner can watch \
live and take over when you ask with browser_ask_owner. The desktop_ tools read the apps open on \
the owner's own computer, app by app as the owner allows. The owner writes to you directly; messages from other bots and from Botloft start with \
diff --git a/crates/botloftd/tests/agent_keys.rs b/crates/botloftd/tests/agent_keys.rs
new file mode 100644
index 00000000..e543ebfe
--- /dev/null
+++ b/crates/botloftd/tests/agent_keys.rs
@@ -0,0 +1,155 @@
+//! An agent's keys (spec 32): the owner saves one, the agent restarts with
+//! it in its environment and its name in its rules, and the value is never
+//! kept as it is in the chat, the messages or the rules.
+
+mod common;
+
+use botloft_core::protocol::{
+ BotState, KeysDeleteParams, KeysListParams, KeysSetParams, MessagesSendParams,
+};
+use botloftd::service::{ApiError, keys, messages};
+use common::stream;
+use common::supervised::setup;
+
+const TOKEN: &str = "wa-live-1234567890abcdef";
+
+fn set(name: &str, value: &str) -> KeysSetParams {
+ KeysSetParams {
+ bot_id: botloft_core::ids::BotId::generate(),
+ name: name.to_owned(),
+ value: value.to_owned(),
+ description: None,
+ request_id: None,
+ }
+}
+
+#[tokio::test(start_paused = true)]
+async fn a_saved_key_reaches_the_agent_by_name_and_goes_when_deleted() {
+ let s = setup().await;
+ let first = s.runtime.process(1).await;
+ s.until(BotState::Idle).await;
+ assert_eq!(first.env("WA_TOKEN"), None);
+
+ let listed = keys::set(
+ &s.daemon,
+ KeysSetParams {
+ bot_id: s.bot.clone(),
+ name: "WA_TOKEN".to_owned(),
+ value: format!(" {TOKEN} "),
+ description: Some("Token do gateway do WhatsApp".to_owned()),
+ request_id: None,
+ },
+ )
+ .expect("set");
+ assert_eq!(listed.len(), 1);
+ assert_eq!(listed[0].name, "WA_TOKEN");
+ assert!(!listed[0].missing);
+
+ let second = s.runtime.process(2).await;
+ assert!(first.killed(), "the agent restarts to get its key");
+ assert_eq!(second.env("WA_TOKEN").as_deref(), Some(TOKEN));
+ let rules =
+ std::fs::read_to_string(second.spec.cwd.join(".claude/rules/keys.md")).expect("keys rules");
+ assert!(
+ rules.contains("`WA_TOKEN`: Token do gateway do WhatsApp"),
+ "{rules}"
+ );
+ assert!(!rules.contains(TOKEN));
+ let stored = keys::list(
+ &s.daemon,
+ KeysListParams {
+ bot_id: s.bot.clone(),
+ },
+ )
+ .expect("list");
+ assert_eq!(stored, listed);
+
+ s.until(BotState::Idle).await;
+ keys::delete(
+ &s.daemon,
+ KeysDeleteParams {
+ bot_id: s.bot.clone(),
+ name: "WA_TOKEN".to_owned(),
+ },
+ )
+ .expect("delete");
+ let third = s.runtime.process(3).await;
+ assert_eq!(third.env("WA_TOKEN"), None);
+ assert!(!third.spec.cwd.join(".claude/rules/keys.md").exists());
+}
+
+#[tokio::test(start_paused = true)]
+async fn what_the_agent_prints_and_the_owner_sends_keep_only_the_name() {
+ let s = setup().await;
+ s.runtime.process(1).await;
+ s.until(BotState::Idle).await;
+ let mut params = set("WA_TOKEN", TOKEN);
+ params.bot_id = s.bot.clone();
+ keys::set(&s.daemon, params).expect("set");
+ let process = s.runtime.process(2).await;
+ s.until(BotState::Idle).await;
+
+ // The owner pastes the value in a message by mistake.
+ let sent = messages::send(
+ &s.daemon,
+ MessagesSendParams {
+ bot_id: s.bot.clone(),
+ body: format!("use {TOKEN} please"),
+ attachments: None,
+ reply_to: None,
+ },
+ )
+ .expect("send");
+ assert_eq!(sent.body, "use [chave WA_TOKEN] please");
+
+ // The agent echoes it.
+ process
+ .emit(stream::tool_use(
+ "toolu_1",
+ "PowerShell",
+ serde_json::json!({ "command": format!("echo {TOKEN}") }),
+ ))
+ .await;
+ process
+ .emit(stream::tool_result("toolu_1", TOKEN, false))
+ .await;
+ process.emit(stream::text(&format!("It is {TOKEN}."))).await;
+ let mut kept = String::new();
+ for _ in 0..200 {
+ let history = s
+ .daemon
+ .store()
+ .chat_history(&s.bot, None, 20)
+ .expect("history");
+ kept = serde_json::to_string(&history).expect("json");
+ if kept.contains("It is [chave WA_TOKEN].") {
+ break;
+ }
+ tokio::time::sleep(std::time::Duration::from_millis(10)).await;
+ }
+ assert!(kept.contains("It is [chave WA_TOKEN]."), "{kept}");
+ assert!(!kept.contains(TOKEN), "{kept}");
+}
+
+#[tokio::test(start_paused = true)]
+async fn reserved_names_and_empty_values_are_refused() {
+ let s = setup().await;
+ s.runtime.process(1).await;
+ for (name, value) in [
+ ("PATH", TOKEN),
+ ("ANTHROPIC_API_KEY", TOKEN),
+ ("lower", TOKEN),
+ ("WA_TOKEN", " "),
+ ] {
+ let mut params = set(name, value);
+ params.bot_id = s.bot.clone();
+ assert!(
+ matches!(keys::set(&s.daemon, params), Err(ApiError::Validation(_))),
+ "{name}"
+ );
+ }
+ assert!(matches!(
+ keys::set(&s.daemon, set("WA_TOKEN", TOKEN)),
+ Err(ApiError::NotFound(_))
+ ));
+}
diff --git a/crates/botloftd/tests/common/context.rs b/crates/botloftd/tests/common/context.rs
index 3e8d5364..89dd7f07 100644
--- a/crates/botloftd/tests/common/context.rs
+++ b/crates/botloftd/tests/common/context.rs
@@ -95,6 +95,7 @@ pub fn kinds(s: &Setup) -> Vec<&'static str> {
ChatBody::Question(_) => "question",
ChatBody::Turn(_) => "turn",
ChatBody::Notice(_) => "notice",
+ ChatBody::KeyRequest(_) => "key_request",
})
.collect()
}
diff --git a/crates/botloftd/tests/key_requests.rs b/crates/botloftd/tests/key_requests.rs
new file mode 100644
index 00000000..b84feeb4
--- /dev/null
+++ b/crates/botloftd/tests/key_requests.rs
@@ -0,0 +1,217 @@
+//! A bot asks the owner for a key (spec 32.5): `ask_key` returns at once,
+//! the request is a card in its chat, and the owner's answer reaches the bot
+//! as a message from Botloft, after the restart that brings the key.
+
+mod common;
+
+use common::bots::{ready_bot, text_of};
+use common::mcp::Mcp;
+use common::stream;
+use common::supervised::session_of;
+use common::{Client, TestDaemon};
+use serde_json::{Value, json};
+
+const TOKEN: &str = "ghp-live-1234567890abcdef";
+
+async fn one_bot() -> (
+ TestDaemon,
+ Client,
+ Value,
+ botloftd::runtime::fake::FakeProcess,
+ Mcp,
+) {
+ let t = TestDaemon::start_supervised().await;
+ let mut app = t.session().await;
+ let crew = app
+ .call("crews.create", json!({ "name": "Ops" }))
+ .await
+ .expect("crew");
+ let (bot, process, mcp) = ready_bot(&t, &mut app, &crew, "Scout").await;
+ (t, app, bot, process, mcp)
+}
+
+async fn card(app: &mut Client, bot: &Value) -> Value {
+ let history = app
+ .call("chat.history", json!({ "botId": bot["id"] }))
+ .await
+ .expect("history");
+ history
+ .as_array()
+ .expect("array")
+ .iter()
+ .find(|item| item["body"]["kind"] == "key_request")
+ .cloned()
+ .expect("a key request in the chat")
+}
+
+#[tokio::test]
+async fn a_saved_key_closes_the_request_and_the_restarted_bot_is_told() {
+ let (t, mut app, bot, first, mut mcp) = one_bot().await;
+
+ let asked = mcp
+ .tool(
+ "ask_key",
+ json!({ "name": "GITHUB_TOKEN", "why": "To open the pull request" }),
+ )
+ .await
+ .expect("asked");
+ let id = asked["request_id"].as_str().expect("id").to_owned();
+ assert!(id.starts_with("cht_"), "{id}");
+ let shown = card(&mut app, &bot).await;
+ assert_eq!(shown["id"], id);
+ assert_eq!(shown["body"]["name"], "GITHUB_TOKEN");
+ assert_eq!(shown["body"]["status"], "open");
+ let lines = app.call("bots.list", json!({})).await.expect("bots");
+ assert_eq!(lines[0]["lastActivity"]["kind"], "question");
+
+ // The key must be the one asked for.
+ let wrong = app
+ .call(
+ "keys.set",
+ json!({ "botId": bot["id"], "name": "OTHER_TOKEN", "value": TOKEN, "requestId": id }),
+ )
+ .await
+ .expect_err("another name");
+ assert!(wrong.message.contains("GITHUB_TOKEN"), "{}", wrong.message);
+
+ app.call(
+ "keys.set",
+ json!({ "botId": bot["id"], "name": "GITHUB_TOKEN", "value": TOKEN, "requestId": id }),
+ )
+ .await
+ .expect("saved");
+ assert_eq!(card(&mut app, &bot).await["body"]["status"], "saved");
+
+ for _ in 0..500 {
+ if first.killed() && t.runtime.processes().len() > 1 {
+ break;
+ }
+ tokio::time::sleep(std::time::Duration::from_millis(10)).await;
+ }
+ assert!(first.killed(), "the bot restarts with its key");
+ let second = t.process_of(&bot).await;
+ assert_eq!(second.env("GITHUB_TOKEN").as_deref(), Some(TOKEN));
+ let lines = second.wait_lines(1).await;
+ let text = text_of(&lines[0]);
+ assert!(text.contains("saved the key GITHUB_TOKEN"), "{text}");
+ assert!(!text.contains(TOKEN), "{text}");
+
+ let again = app
+ .call(
+ "keys.set",
+ json!({ "botId": bot["id"], "name": "GITHUB_TOKEN", "value": TOKEN, "requestId": id }),
+ )
+ .await
+ .expect_err("closed");
+ assert_eq!(again.code, -32003);
+}
+
+#[tokio::test]
+async fn not_now_closes_the_request_and_tells_the_bot() {
+ let (_t, mut app, bot, process, mut mcp) = one_bot().await;
+ let asked = mcp
+ .tool(
+ "ask_key",
+ json!({ "name": "STRIPE_KEY", "why": "To read last month's invoices" }),
+ )
+ .await
+ .expect("asked");
+ app.call("keys.dismiss", json!({ "requestId": asked["request_id"] }))
+ .await
+ .expect("dismissed");
+ assert_eq!(card(&mut app, &bot).await["body"]["status"], "dismissed");
+ let lines = process.wait_lines(1).await;
+ let text = text_of(&lines[0]);
+ assert!(text.contains("did not give the key STRIPE_KEY"), "{text}");
+ let keys = app
+ .call("keys.list", json!({ "botId": bot["id"] }))
+ .await
+ .expect("keys");
+ assert_eq!(keys, json!([]));
+}
+
+#[tokio::test]
+async fn bad_requests_come_back_to_the_bot_and_open_ones_are_capped() {
+ let (_t, _app, _bot, _process, mut mcp) = one_bot().await;
+ for (args, says) in [
+ (json!({ "name": "lower", "why": "x" }), "capital letters"),
+ (json!({ "name": "PATH", "why": "x" }), "used by the system"),
+ (json!({ "name": "A_KEY", "why": " " }), "what you need"),
+ (
+ json!({ "name": "A_KEY", "why": "x".repeat(501) }),
+ "at most 500",
+ ),
+ (
+ json!({ "name": "A_KEY", "why": "x", "value": "y" }),
+ "invalid arguments",
+ ),
+ ] {
+ let err = mcp.tool("ask_key", args).await.expect_err("refused");
+ assert!(err.contains(says), "{err}");
+ }
+ for n in 0..5 {
+ mcp.tool(
+ "ask_key",
+ json!({ "name": format!("KEY_{n}"), "why": "For a test" }),
+ )
+ .await
+ .expect("asked");
+ }
+ let err = mcp
+ .tool("ask_key", json!({ "name": "KEY_5", "why": "For a test" }))
+ .await
+ .expect_err("capped");
+ assert!(err.contains("wait for an answer"), "{err}");
+}
+
+#[tokio::test]
+async fn a_key_saved_mid_turn_reaches_the_restarted_bot_not_the_old_one() {
+ let (t, mut app, bot, first, mut mcp) = one_bot().await;
+ let asked = mcp
+ .tool(
+ "ask_key",
+ json!({ "name": "GITHUB_TOKEN", "why": "To open the PR" }),
+ )
+ .await
+ .expect("asked");
+ // The owner writes while the bot is still in its turn.
+ app.call(
+ "messages.send",
+ json!({ "botId": bot["id"], "body": "and then?" }),
+ )
+ .await
+ .expect("sent");
+ let sent = first.wait_lines(1).await;
+ app.call(
+ "keys.set",
+ json!({
+ "botId": bot["id"], "name": "GITHUB_TOKEN", "value": TOKEN,
+ "requestId": asked["request_id"],
+ }),
+ )
+ .await
+ .expect("saved");
+ tokio::time::sleep(std::time::Duration::from_millis(300)).await;
+ assert_eq!(
+ first.input_lines().len(),
+ 1,
+ "the old process gets nothing more"
+ );
+
+ let session = session_of(&first);
+ first.emit(stream::init(&session)).await;
+ first.emit(stream::replay(&sent[0])).await;
+ first.emit(stream::result(false)).await;
+ for _ in 0..500 {
+ if first.killed() && t.runtime.processes().len() > 1 {
+ break;
+ }
+ tokio::time::sleep(std::time::Duration::from_millis(10)).await;
+ }
+ assert!(first.killed(), "the turn ended, so the bot restarts");
+ let second = t.process_of(&bot).await;
+ assert_eq!(second.env("GITHUB_TOKEN").as_deref(), Some(TOKEN));
+ let lines = second.wait_lines(1).await;
+ let text = text_of(&lines[0]);
+ assert!(text.contains("saved the key GITHUB_TOKEN"), "{text}");
+}
diff --git a/crates/botloftd/tests/key_scan.rs b/crates/botloftd/tests/key_scan.rs
new file mode 100644
index 00000000..9d2a7530
--- /dev/null
+++ b/crates/botloftd/tests/key_scan.rs
@@ -0,0 +1,111 @@
+//! Keys pasted before they were kept apart (spec 32.7): the scan shows only
+//! their start and end, keeping one saves it for the agent and leaves its
+//! name in the texts, and deleting one leaves "[chave removida]".
+
+mod common;
+
+use botloft_core::protocol::{BotState, KeyKeep, KeyScrub, KeysScrubParams, MessagesSendParams};
+use botloftd::service::{ApiError, key_scan, messages};
+use common::supervised::setup;
+
+const GITHUB: &str = "ghp_abcdefghijklmnopqrstuvwxyz0123";
+// In two parts, so the push protection of a code host does not take it for
+// a real token.
+const SLACK: &str = concat!("xox", "b-1234567890-abcdefghijklmnop");
+
+#[tokio::test(start_paused = true)]
+async fn pasted_keys_are_kept_by_name_or_deleted() {
+ let s = setup().await;
+ let first = s.runtime.process(1).await;
+ s.until(BotState::Idle).await;
+ messages::send(
+ &s.daemon,
+ MessagesSendParams {
+ bot_id: s.bot.clone(),
+ body: format!("the token is {GITHUB} and slack is {SLACK}"),
+ attachments: None,
+ reply_to: None,
+ },
+ )
+ .expect("send");
+
+ let hits = key_scan::scan(&s.daemon).expect("scan");
+ assert_eq!(hits.len(), 2);
+ let shown = serde_json::to_string(&hits).expect("json");
+ assert!(!shown.contains(GITHUB) && !shown.contains(SLACK), "{shown}");
+ let github = hits
+ .iter()
+ .find(|hit| hit.preview == "ghp_…0123")
+ .expect("github");
+ let slack = hits
+ .iter()
+ .find(|hit| hit.preview == "xoxb-…mnop")
+ .expect("slack");
+ assert_eq!(github.bot_ids, std::slice::from_ref(&s.bot));
+ // The message and its chat item.
+ assert_eq!(github.places, 2);
+
+ // A refused name leaves everything as it was.
+ let refused = key_scan::scrub(
+ &s.daemon,
+ KeysScrubParams {
+ hits: vec![KeyScrub {
+ id: github.id.clone(),
+ keep: Some(KeyKeep {
+ bot_id: s.bot.clone(),
+ name: "PATH".to_owned(),
+ }),
+ }],
+ },
+ );
+ assert!(matches!(refused, Err(ApiError::Validation(_))));
+ assert_eq!(key_scan::scan(&s.daemon).expect("scan").len(), 2);
+
+ let left = key_scan::scrub(
+ &s.daemon,
+ KeysScrubParams {
+ hits: vec![
+ KeyScrub {
+ id: github.id.clone(),
+ keep: Some(KeyKeep {
+ bot_id: s.bot.clone(),
+ name: "GITHUB_TOKEN".to_owned(),
+ }),
+ },
+ KeyScrub {
+ id: slack.id.clone(),
+ keep: None,
+ },
+ ],
+ },
+ )
+ .expect("scrub");
+ assert!(left.is_empty());
+
+ let history = s
+ .daemon
+ .store()
+ .chat_history(&s.bot, None, 20)
+ .expect("history");
+ let kept = serde_json::to_string(&history).expect("json");
+ assert!(
+ kept.contains("the token is [chave GITHUB_TOKEN] and slack is [chave removida]"),
+ "{kept}"
+ );
+ assert!(!kept.contains(GITHUB) && !kept.contains(SLACK), "{kept}");
+
+ let second = s.runtime.process(2).await;
+ assert!(first.killed(), "the agent restarts with the kept key");
+ assert_eq!(second.env("GITHUB_TOKEN").as_deref(), Some(GITHUB));
+
+ let gone = key_scan::scrub(
+ &s.daemon,
+ KeysScrubParams {
+ hits: vec![KeyScrub {
+ id: slack.id.clone(),
+ keep: None,
+ }],
+ },
+ );
+ assert!(matches!(gone, Err(ApiError::NotFound(_))));
+}
diff --git a/crates/botloftd/tests/mcp.rs b/crates/botloftd/tests/mcp.rs
index ae06e0fb..a750389b 100644
--- a/crates/botloftd/tests/mcp.rs
+++ b/crates/botloftd/tests/mcp.rs
@@ -75,6 +75,7 @@ async fn claude_code_discovers_the_server_and_lists_the_tools() {
"schedule_routine",
"share_file",
"ask_owner",
+ "ask_key",
"show_plan",
"send_signal",
"my_routines",
@@ -152,7 +153,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(42)
+ Some(43)
);
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 82198230..e43220a0 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -588,6 +588,7 @@ Transporte: Streamable HTTP, só POST e resposta `application/json`, sem sessão
| `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) |
| `ask_owner` | `question` (até 2 000 caracteres), `options?` (2 a 5, até 100 caracteres cada) | `question_id` e o lembrete de que a resposta chega depois, como mensagem; a tool não espera (23.2) |
+| `ask_key` | `name` (o nome da variável, 32.3), `why` (até 500 caracteres) | `request_id` e o lembrete de que a chave chega depois, num reinício com a variável e numa mensagem do Botloft; a tool não espera (32.5) |
| `show_plan` | `summary` (até 300 caracteres), `steps` (2 a 8, até 200 caracteres cada), `alternative?` (até 300) | `start` (`plan`, `alternative` ou `false`) e o que fazer; espera até 20 s pelo dono (10.5) |
| `permission_prompt` | `tool_name`, `input`, `tool_use_id` | decisão do dono (10.1). Chamada pelo Claude Code, não pelo modelo |
| `browser_*` | seção 21.4 | o navegador do bot: abrir, ler, clicar, digitar, rolar, ver a tela, pedir a mão do dono |
@@ -1144,6 +1145,7 @@ M2 a M4 foram entregues com ConPTY, terminal com replay, inbox por named pipe e
12. Catálogo de bots: modelos prontos de função (Designer, Pesquisador...) que o dono escolhe na Agência de bots e adiciona à equipe, e que o chefe usa para sugerir bots com instruções melhores. Desenho na seção 26.
13. Modelos de equipe (começar uma equipe já com os bots certos) e resumo diário (o chefe conta, no fim do dia, o que cada bot fez). Desenho na seção 29.
14. Ilha: o Botloft no topo da tela, uma pílula que vira painel, com os pedidos e as perguntas de todas as equipes, os agentes ao vivo, uma conversa rápida e o uso do plano, sem abrir o Botloft. Desenho na seção 31.
+15. Chaves dos agentes: o dono guarda fora da conversa as chaves de que um agente precisa, e o agente as usa pelo nome, como variável de ambiente. Desenho na seção 32.
## 19. Pontos a verificar na versão alvo do Claude Code
@@ -1217,6 +1219,7 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28; esf
| Celular (aprovar e responder de longe) | 28 | **Visto** (2026-10-08), com a VPS atualizada e um **Android de verdade** (Chrome, instalado como app); **não visto** no iPhone (sem aparelho). **Servidor:** a imagem do `deploy/cloud/Dockerfile` inteiro foi levada à VPS do Coolify (`docker load`), com `phone_dir`, `[push]` e a chave em `BOTLOFT_CLOUD_VAPID_KEY` (variável só de execução); subiu sem erro, `/health` 200, `/m` com a política atrás da Cloudflare, o JavaScript igual ao do build local, e o **WebSocket do relay abre atravessando a Cloudflare** (`101`). **Computador:** `pnpm local` trocou o app e o daemon (hashes iguais aos do build). **No aparelho:** (1) a **câmera do Android preservou o `#`** do QR; os seis dígitos eram iguais nos dois lados; instalou como app; (2) **Permitir** um pedido de escrever um arquivo (o cartão chegou sozinho, o bot seguiu); (3) **Negar com recado** (o cartão sumiu, o bot citou o recado, o arquivo ficou); (4) uma pergunta de `ask_owner` com duas opções, respondida pelo celular (o bot recebeu "verde"); (5) **aviso com a tela bloqueada e o app fechado**: chegou "O Botloft precisa de você" e tocar abriu o cartão; (6) desconectar **pelo computador** (o app aberto mostrou "foi desconectado" e voltou à conexão) e **pelo celular** (o computador deixou de listá-lo); (7) conectar de novo com um código novo no mesmo celular; (8) um pedido de rotina (que só se resolve no computador) chegou com o **Permitir apagado** e o aviso "precisa de você no computador", e **sumiu sozinho** do celular quando foi aprovado no computador. **Achados:** uma primeira instalação do app que travou pela metade deixou os avisos sem ligar até reinstalar (nada a corrigir no código; vale uma linha na ajuda); e o painel de navegador do app não registra service workers, então o aviso só se vê em aparelho de verdade. **Falta ver:** iPhone com a página na Tela de Início (Safari: câmera e `#`, Web Crypto, avisos); a conexão do relay aberta por horas atrás da Cloudflare; vários avisos seguidos virando dois (só testado automaticamente); sair da conta no computador levando os celulares (só testado automaticamente) | em parte |
| Entrada do `Write` e do `Edit` para as contagens e o diff | 8.2, 15.3 | **Testado com 2.1.294** (2026-10-22, daemon de dev em pasta própria, app de dev com `?live`, Haiku, modo Manual): pedido para criar `notas.md` com 5 linhas pelo `Write` e trocar uma linha por duas pelo `Edit`. O `Write` chegou com `file_path` e `content`, e o `Edit` com `file_path`, `old_string` e `new_string` (sem contexto em volta: só a linha trocada). A linha mostrou "+5" no `Write` e "+2 −1" no `Edit` já no pedido de permissão, e abertas, os diffs certos: cinco linhas adicionadas; "- pão" removida, "- pão integral" e "- manteiga" adicionadas. O arquivo ficou como pedido. O texto da conversa não foi para o log | feito |
| Plano antes de tarefa longa (`show_plan`) | 10.5 | **Testado com 2.1.309** (2026-10-25, daemon de dev em pasta própria, comandado por script pelo WebSocket, Sonnet, modo Manual, opção ligada): (1) um pedido de três arquivos e revisão: depois do `ToolSearch` que carrega as tools do Botloft, a primeira ferramenta foi `show_plan`, com resumo e cinco passos em português e sem `alternative`; sem resposta, o cartão virou `expired` em 20 s e o bot seguiu o plano. (2) um pedido de tradução respondido com Mudar e uma nota: o bot não traduziu e fez o que a nota pedia. (3) uma pergunta rápida: nenhum cartão. O log do daemon não guardou texto das mensagens nem do plano. Ainda não visto: o bot oferecer uma `alternative` sozinho (nenhum dos dois planos trouxe) e o cartão no app instalado. |
+| Chave pedida pelo agente (`ask_key`) | 32.4, 32.5, 32.6 | **Testado com 2.1.309** (2026-10-26, daemon de teste em pasta própria, comandado por script pelo WebSocket, Haiku, modo Manual): o agente carregou `ask_key` pelo `ToolSearch` sozinho, pediu `DEMO_TOKEN` e terminou o turno. O dono salvou um valor de teste pelo `requestId` **enquanto o turno ainda corria**. Na primeira tentativa, o aviso "a chave está no seu ambiente" foi entregue ao processo antigo antes do reinício, e o agente viu a variável vazia. Agora nenhuma mensagem é escrita no processo enquanto um reinício por configuração nova espera (o carteiro adia e entrega ao processo novo). Com a correção, o agente reiniciou, recebeu o aviso, rodou `$env:DEMO_TOKEN.Length` (31, certo) e **recusou imprimir o valor** pela regra do `keys.md`. Com a permissão do dono, `Write-Output $env:DEMO_TOKEN` chegou ao chat como `[chave DEMO_TOKEN]`. O valor não apareceu no log do daemon, no banco nem na pasta do agente, só no arquivo de `secretskeys` e no histórico de sessão do próprio Claude Code (32.6) | feito |
Itens do runtime anterior (ConPTY, hooks em exec form, `crossSessionInbound`, linha de auth do inbox, diálogo de confiança, `ESC[6n` do ConPTY, consultas do terminal no replay) foram verificados no M2–M4 e deixaram de se aplicar com a ADR 0001; o histórico está no git e na ADR.
@@ -2856,3 +2859,82 @@ Textos nos três idiomas (15.6), sem jargão: "Precisa de você no Botloft", "Ab
| **I3** Agentes ao vivo, comemoração e resumo | cartões dos agentes com passos e diff, texto ao vivo, "+2 trabalhando", comemoração, cartão Hoje | `FakeBotloft`: um agente edita e o cartão mostra o passo e o diff; outro termina e comemora; o resumo com os números do fake |
| **I4** Conversa, arquivo e uso | aba Conversa com escolha do agente, texto ao vivo e envio; arrastar arquivo; aba Uso com plano e rotinas, "Rodar agora"; anel na pílula | `FakeBotloft`: mandar e ver a resposta; arquivo anexado e grande demais recusado; barras na cor de aviso a partir de 80%; rotinas na ordem com "em 25 min" |
| **I5** Quando aparece e teste real | descer, pílula, espiar, janela principal na frente, tela cheia e apresentação, no lugar do aviso do Windows; Configurações; CPU medida | lógica de quando aparecer com relógio falso (`FakeHost`); `quiet.rs` com os estados do Windows; **a verificar** (19): sem foco ao aparecer, transparência no WebView2, sobre tela cheia; CPU pelo CDP (`webview-frame-measuring`); manual com o Claude Code real: com o Botloft atrás de outra janela, um agente pede um comando, a ilha desce sem foco, Permitir pela ilha, o chat mostra "Permitido"; outro agente termina e comemora |
+
+## 32. Chaves dos agentes
+
+Status: **K1 a K4 feitos** (guardar, usar e esconder; o pedido pelo agente; o app; as já coladas), com o teste no Claude Code real (seção 19). Falta o pedido na ilha (32.5). A ideia veio do OpenBot (`docs/openbot-analysis.md`, fora do git); a implementação é nossa.
+
+### 32.1 O que é
+
+Alguns agentes precisam de uma chave ou senha de outro serviço para trabalhar: o token de um gateway de WhatsApp, uma chave do Stripe, do GitHub, de uma API de e-mail. Hoje o dono a cola no chat, e aí ela fica na conversa do Botloft (no banco), vai ao modelo junto com o resto, fica no histórico de sessão do Claude Code, entra na cópia na nuvem (27) e o agente pode repeti-la numa resposta ou mandá-la a outro agente.
+
+Com as **chaves**, o dono guarda a chave fora da conversa e o agente a usa **pelo nome**, sem nunca ver o valor no chat: ela chega ao processo do agente como variável de ambiente.
+
+### 32.2 Onde a chave fica
+
+- **Por agente.** Cada chave é de um agente (`bot_id`) e tem um **nome** e, se o dono quiser, uma **descrição** ("Token do gateway do WhatsApp"). Outro agente da mesma equipe não a recebe.
+- **Em `secrets\keys\.json`**, com a permissão só do usuário (ACL, 13), como os segredos das ferramentas conectadas (25.2). **Nunca** no banco, no log, no workspace do agente, na cópia de segurança (14.2) nem na nuvem (27). O banco guarda só os nomes, as descrições e quando mudaram, para a lista.
+- **Só de escrita.** O app nunca mostra o valor de volta: "Trocar" pede um valor novo. O daemon não tem método que devolva o valor.
+- **Computador novo.** Restaurar uma cópia traz os nomes e as descrições, sem os valores: cada chave aparece como "Falta o valor" até o dono colar de novo, e o agente sobe sem a variável.
+
+### 32.3 O nome
+
+- `[A-Z][A-Z0-9_]{1,63}`, único no agente, por exemplo `WHATSAPP_TOKEN`.
+- Recusados: `PATH`, `PATHEXT`, `SYSTEMROOT`, `COMSPEC`, `TEMP`, `TMP`, `USERPROFILE`, `HOME`, `APPDATA`, `LOCALAPPDATA`, e qualquer um que comece com `BOTLOFT_`, `CLAUDE`, `ANTHROPIC_`, `CODEX_`, `OPENAI_` ou `NODE_`: são do sistema, do Botloft ou do motor, e trocá-los mudaria como o agente roda (7.4).
+- Com o motor que lê do ambiente, o nome é o que o agente usa: `$env:WHATSAPP_TOKEN` no PowerShell, `$WHATSAPP_TOKEN` no Bash, ou um programa que lê a variável.
+
+### 32.4 Como o agente usa
+
+- **No ambiente do processo.** Ao subir o agente (7.4, passo 5), o daemon põe as chaves dele no ambiente, depois do bloco do usuário e antes das variáveis do Botloft, que não podem ser sobrescritas. Vale para os três motores (30).
+- **Mudou, reinicia.** Salvar, trocar ou apagar uma chave reinicia o agente com `--resume` quando nada estiver em andamento, como na troca de modo (7.4) e nas ferramentas conectadas (25.3): a conversa continua, e o processo novo já tem a variável. Enquanto esse reinício espera o turno acabar, nenhuma mensagem nova é escrita no processo antigo: o carteiro (9) adia e ela chega ao processo novo, com a configuração nova (visto com o Claude Code real, seção 19).
+- **Regras do agente:** um arquivo próprio, `.claude/rules/keys.md`, escrito a cada início (e apagado sem chaves), com a lista dos nomes, as descrições, e a regra; os outros motores (30) recebem o mesmo texto no fim do `AGENTS.md`. A regra: use a chave pelo nome da variável; nunca escreva o valor numa resposta, num arquivo, numa mensagem a outro agente nem num comando que o imprima (`echo`, `Write-Output`); se uma chave faltar ou não funcionar, peça com `ask_key`, nunca no chat.
+
+### 32.5 Como o agente pede
+
+- **A tool `ask_key {name, why}`** (10): `name` como em 32.3, `why` em até 500 caracteres, na língua do dono. Volta na hora, como `ask_owner` (23.2), com o lembrete de que a chave chega depois numa mensagem do Botloft e de que o agente não deve esperar: segue com o que não depende dela ou termina o turno. Até 5 pedidos abertos por agente; vale em qualquer modo de permissão.
+- **O cartão** no chat: " precisa de uma chave", o nome, o porquê, um campo de senha (com o olho para mostrar o que foi digitado), a descrição opcional, e **Salvar** e **Agora não**. Salvo, vira uma linha "Você guardou a chave WHATSAPP_TOKEN" (nunca o valor). "Agora não" fecha o pedido e o agente recebe "o dono não deu a chave agora".
+- **Depois de salva**, o agente reinicia (32.4) e recebe uma mensagem do Botloft: "A chave WHATSAPP_TOKEN está no seu ambiente." Uma chave que já existia e foi trocada diz isso também.
+- **O dono também cadastra sem pedido**, nos detalhes do agente (15.1), na parte **Chaves**: a lista com nome, descrição e quando mudou, "Trocar", "Apagar" e "Adicionar chave".
+- **Fora do celular e da ilha.** O pedido nunca vai ao celular (28.5): colar uma senha é coisa do computador. Na ilha, ele deve aparecer como "Precisa de você no Botloft" (31.5); isso ainda falta e entra junto com a janela da ilha. Na lista de agentes, a última atividade dele é uma pergunta.
+
+### 32.6 O que o Botloft esconde
+
+- **Do que ele guarda.** Antes de gravar um item do chat (8.2: resposta, resumo, explicação, entrada e saída de ferramenta, pedido de aprovação, mensagem recebida, aviso) ou uma message (9), o daemon troca cada valor de chave do agente (com 8 caracteres ou mais, o mais longo primeiro) por `[chave NOME]` (`botloft_core::keys::redact`, chamado em `chat/items.rs`, na criação do pedido de aprovação e em `messages::post`). Numa message, valem as chaves de quem recebe e de quem manda. As chaves ficam num cache em memória do daemon, lido do arquivo uma vez e esquecido a cada mudança. Assim, um `echo` por engano não leva a chave ao banco, ao app, ao celular, à busca (8.8) nem à cópia na nuvem.
+- **Das mensagens do dono.** Se o dono colar no chat o valor de uma chave já guardada, a mensagem chega ao agente com `[chave NOME]` no lugar, e o app avisa, acima da caixa de mensagem, que ela já está guardada (compara o que foi digitado com a mensagem que o daemon devolve).
+- **O que fica fora do alcance.** O histórico de sessão do próprio Claude Code (`%USERPROFILE%\.claude\projects\...`) e o que o modelo recebe são do motor: se o agente imprimir o valor apesar da regra, ele vai para lá. A regra de 32.4 e a troca de 32.6 reduzem isso; não é uma garantia, e o app diz isso na parte Chaves.
+
+### 32.7 Chaves que já foram coladas
+
+- **"Procurar chaves"**, nas Configurações (página Segurança, nova, no grupo Seus dados): o daemon procura no banco, em todo texto que guarda (mensagens, itens do chat, pedidos de aprovação com a entrada e o recado, perguntas e respostas, resultados de tarefas), textos com cara de chave: prefixos conhecidos (`sk-`, `sk-ant-`, `ghp_`, `github_pat_`, `xoxb-`, `xoxp-`, `AKIA`, `AIza`, `rk_live_`, `sk_live_`, `whsec_`) seguidos de pelo menos 16 caracteres de token, e JWT (`eyJ...` com dois pontos), sempre no começo de uma palavra (`botloft_core::key_scan`). Cada valor aparece uma vez, com os agentes em cujas conversas está (o mais recente primeiro), em quantos lugares e a data mais nova, e só o começo e o fim (`sk-…a1b2`).
+- Para cada achado: **"Guardar como chave"** (escolhe o agente, já com o mais recente, e o nome; guarda como em 32.2, o que reinicia o agente, e só então troca o texto no banco por `[chave NOME]`: um nome recusado não muda nada) ou **"Apagar do Botloft"**, depois de confirmar (troca por `[chave removida]`). A troca vale em todos os textos de uma vez, numa transação, e o índice da busca (8.8) acompanha pelos gatilhos.
+- **O que isso não alcança**, dito na tela em palavras simples: o histórico de sessão do Claude Code (o Botloft não mexe nele, porque isso pode quebrar a conversa do agente; "Começar conversa nova" no agente deixa ela para trás) e as cópias na nuvem já feitas (a próxima sai sem a chave; apagar as antigas é em Conta, 27.6). E o mais importante: **trocar a chave no serviço de origem**, porque ela já passou por lugares que o Botloft não controla.
+
+### 32.8 Segurança
+
+- Nada de valor de chave no log, em nível nenhum (13); o daemon loga só "chave salva" com o nome.
+- O valor existe em três lugares: o arquivo em `secrets\`, a memória do daemon enquanto sobe o agente e o ambiente do processo do agente (e dos comandos que ele roda, que herdam o ambiente).
+- Um agente não lê as chaves de outro: o arquivo é por agente e só o daemon o abre; `secrets\` já está nas regras de negar leitura (7.5).
+- `ask_key` não salva nada: só abre o cartão. Só o dono, no app, grava uma chave.
+
+### 32.9 Protocolo e dados
+
+- **Métodos** (11.2, só do dono): `keys.list {botId}` → `[{name, description, setAt, missing}]`; `keys.set {botId, name, value, description?, requestId?}`; `keys.delete {botId, name}`; `keys.dismiss {requestId}`; `keys.scan` → `[{id, preview, botIds, places, lastAt}]` (32.7, sem o valor; o `id` é o começo do SHA-256 do valor); `keys.scrub {hits: [{id, keep}]}`, com `keep` `{botId, name}` para guardar ou `null` para apagar, e a resposta é uma busca nova.
+- **Sem notificação própria:** a parte Chaves lê de novo depois de cada mudança do dono e quando um pedido do agente fica `saved`, que chega como `chat.item`. O cartão é um item novo do chat, `key_request` (`name`, `why`, `status`: `open`, `saved`, `dismissed`), regravado quando o dono responde. O pedido **é** esse item: o `requestId` é o id dele.
+- **Banco:** migration com `agent_keys` (`bot_id`, `name`, `description`, `set_at`). Nenhum valor. O pedido não tem tabela própria: vive em `chat_items` (tipo `key_request`), e o limite de 5 conta os abertos ali. Uma tabela à parte repetiria o item sem ganho.
+- **App:** o cartão em `features/chat/KeyRequestCard.tsx`, a parte Chaves nos detalhes do agente, a busca nas Configurações; textos nos três idiomas.
+
+### 32.10 Fora desta etapa
+
+- Chave da equipe inteira (por enquanto, cada agente tem as suas).
+- Chaves vindas de um cofre de fora (1Password, Bitwarden) e do cofre do Windows.
+- Validade e lembrete de troca.
+- Mexer no histórico de sessão do Claude Code.
+
+### 32.11 Marcos
+
+| Marco | O que entra | Teste |
+|---|---|---|
+| **K1** Guardar e usar | `secrets\keys`, `keys.list/set/delete`, o nome validado, a variável no ambiente do agente nos três motores, o reinício com `--resume`, as regras do agente com os nomes, a troca por `[chave NOME]` no que o daemon grava | `tests/agent_keys.rs`: o agente sobe com a variável e o `keys.md`, e sem eles depois de apagada; nomes reservados e valor vazio recusados; o comando, a saída, a resposta e a mensagem do dono com o valor ficam com `[chave NOME]`; `keys.rs` do núcleo (nome, valor, troca), `agent_keys.rs` do daemon e do store. **Feito** |
+| **K2** O pedido | a tool `ask_key`, o item `key_request`, `keys.dismiss`, a mensagem ao agente quando a chave chega, o limite de 5, fora do celular | `tests/key_requests.rs`: pede, o nome errado é recusado, o dono salva pelo `requestId`, o agente reinicia com a variável e recebe a mensagem sem o valor; "Agora não" avisa o agente; nomes e motivos ruins voltam ao agente; o sexto pedido aberto é recusado; `fakeKeys.test.ts`. O celular pula o item no `mobile/chats.rs`. **Feito** |
+| **K3** No app | o cartão com o campo de senha, a parte Chaves nos detalhes, "Falta o valor", o aviso de chave já guardada colada no chat, três idiomas | `features/keys/keys.test.tsx` com o `FakeBotloft` (que troca a chave pelo nome como o daemon): o cartão com o campo escondido e o olho, salvar pelo `requestId` e a linha com o nome, "Agora não", adicionar (o nome vira maiúsculas e _), "Falta o valor", trocar, apagar com confirmação, o aviso depois de colar uma chave guardada; `pnpm check`. Visto na prévia (`seedKeys.ts`). **Feito** |
+| **K4** As já coladas e o teste real | `keys.scan`, `keys.scrub`, a página Segurança nas Configurações | `key_scan.rs` do núcleo (cada prefixo, palavras que não são chave, JWT, texto em JSON e depois de um escape, o começo e o fim); `key_scan.rs` do store (um valor uma vez com os agentes, a troca em todo lugar e na busca); `tests/key_scan.rs` (nada do valor sai na busca, um nome recusado não muda nada, guardar põe `[chave NOME]` e reinicia o agente com a variável, apagar põe `[chave removida]`); `pastedKeys.test.tsx`. Visto na prévia. Manual com o Claude Code real (seção 19): o agente pede a chave, o dono salva no meio do turno, o agente reinicia e usa `$env:NOME`, e o `Write-Output` dela chega ao chat como `[chave NOME]`; daí saiu a correção do 32.4 e `tests/key_requests.rs` ganhou o caso. **Feito** |