Let bots manage their hooks, with the user confirming what widens them

Bots get list_hooks, pause_hook and delete_hook, which act directly since
they only take away. A new hook, or a new name, brief or filter, or
switching a paused one back on, goes through propose_hook: it stores a
hook_proposals row and puts a [hook-proposal:ID] card in the reply, which
the user it was proposed to confirms or rejects. The card is read from
what Confirm saves, with the bot's reason labelled as its own; confirming
checks everything again and saves through the Hooks modal's helpers. A
new hook's URL is shown on the card once, to the user only, never in the
outcome the bot is told. What calls a hook, its computers and its signing
are never proposed. Only chat replies propose, and a hook call's own run
gets no hook tools, so a caller's payload can't reach them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-25 15:18:54 +01:00
co-authored by Claude Opus 5.5
parent 045167715d
commit 380d3ebb2f
54 changed files with 1983 additions and 55 deletions
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, test } from "bun:test";
import describeBotHook from "./describe-bot-hook";
describe("describeBotHook", () => {
test("names a URL hook without any of its token", () => {
const text = describeBotHook({
hook: {
id: 3,
title: "GitHub pushes",
source: "url",
token_prefix: "hk_secretprefix",
has_signing_secret: 1,
enabled: 1,
run_count: 2,
last_called_at: Date.UTC(2026, 9, 25, 9, 0),
workspace_name: "Launch",
},
});
expect(text).toBe(
`#3 "GitHub pushes", called at its URL (signed calls only), in the workspace Launch, on, 2 runs so far, last called 2026-10-25T09:00:00.000Z`,
);
expect(text).not.toContain("hk_secretprefix");
});
test("names a paused notification hook with its filter and last error", () => {
expect(
describeBotHook({
hook: {
id: 4,
title: "Slack mentions",
source: "browser_notifications",
watch_computer_id: 2,
watch_computer_name: "Main",
watch_filter: "slack",
computer_id: 2,
enabled: 0,
run_count: 1,
last_error: "Timed out",
},
}),
).toBe(
`#4 "Slack mentions", called by browser notifications on computer 2 (Main), only those containing "slack", in your own chat, on computer 2, paused, 1 run so far, never called, last error: Timed out`,
);
});
});
+31
View File
@@ -0,0 +1,31 @@
import type { BotHookWithNames } from "@/src/types";
type Params = {
hook: BotHookWithNames;
};
/**
* One hook as a bot reads it: id, name, what calls it, where it runs, its
* state, runs and last call (ISO, UTC), and the last error if any. Never
* its URL or any part of its token: a bot doesn't hold those.
*/
export default function describeBotHook({ hook }: Params) {
const runs = Number(hook.run_count || 0);
const watching = hook.source == "browser_notifications";
const parts = [
`#${hook.id} "${hook.title}"`,
watching
? `called by browser notifications on computer ${hook.watch_computer_id}${hook.watch_computer_name ? ` (${hook.watch_computer_name})` : ""}${
hook.watch_filter ? `, only those containing "${hook.watch_filter}"` : ""
}`
: `called at its URL${hook.has_signing_secret ? " (signed calls only)" : ""}`,
hook.workspace_name ? `in the workspace ${hook.workspace_name}` : `in your own chat`,
hook.computer_id ? `on computer ${hook.computer_id}` : "",
hook.enabled ? "on" : "paused",
`${runs} run${runs == 1 ? "" : "s"} so far`,
hook.last_called_at ? `last called ${new Date(Number(hook.last_called_at)).toISOString()}` : "never called",
hook.last_error ? `last error: ${hook.last_error}` : "",
];
return parts.filter(Boolean).join(", ");
}
@@ -0,0 +1,47 @@
import { describe, expect, test } from "bun:test";
import describeHookProposalDetails from "./describe-hook-proposal-details";
describe("describeHookProposalDetails", () => {
test("a new URL hook shows who calls it, where it runs and its whole brief", () => {
const details = describeHookProposalDetails({
action: "create",
data: { title: "GitHub pushes", instructions: "Summarise each push.", source: "url", computer_id: 3 },
workspace_name: "Launch",
computers: new Map([[3, "Main"]]),
});
expect(details).toEqual([
{ label: "Name", value: "GitHub pushes" },
{ label: "Called by", value: "Anyone with its URL (shown to you once, when you confirm)" },
{ label: "Runs in", value: "The workspace Launch" },
{ label: "Runs on", value: "Main (#3)" },
{ label: "Brief", value: "Summarise each push.", block: true },
]);
});
test("a notification hook runs on the computer it watches unless told otherwise", () => {
const details = describeHookProposalDetails({
action: "create",
data: { title: "Slack", instructions: "Answer.", source: "browser_notifications", watch_computer_id: 2 },
computers: new Map([[2, "Desk"]]),
});
expect(details.find((d) => d.label == "Called by")?.value).toBe("Browser notifications on Desk (#2)");
expect(details.find((d) => d.label == "Takes")?.value).toBe("Every notification");
expect(details.find((d) => d.label == "Runs in")?.value).toBe("The bot's own chat with you");
expect(details.find((d) => d.label == "Runs on")?.value).toBe("Desk (#2)");
});
test("a change shows each field as it is and as it would be, and nothing else", () => {
const details = describeHookProposalDetails({
action: "change",
data: { instructions: "New brief.", enabled: true },
current: { id: 7, title: "GitHub pushes", instructions: "Old brief.", enabled: 0 },
computers: new Map(),
});
expect(details.map((d) => d.label)).toEqual(["Hook", "Brief now", "New brief", "Switch on"]);
expect(details[1]?.value).toBe("Old brief.");
expect(details[2]?.value).toBe("New brief.");
});
});
@@ -0,0 +1,84 @@
import type { HookProposalAction } from "@/src/dict/bot-hook-dict";
import type { HookProposalData } from "@/src/schemas/bot-hook-schema";
import type { BotHookWithNames, ExtensionProposalDetail } from "@/src/types";
type Params = {
action: HookProposalAction;
data: HookProposalData;
/**
* The hook it changes, as it is now.
*/
current?: BotHookWithNames | null;
/**
* Where a new hook runs: the workspace's name, none for the bot's own
* chat.
*/
workspace_name?: string | null;
/**
* Computer names by id.
*/
computers: Map<number, string>;
};
/**
* What a hook proposal would save, line by line for its card, read from
* the data Confirm saves (never from what the bot said): for a new hook
* what calls it, where it runs, on which computer and its whole brief;
* for a change each field as it is and as it would be.
*/
export default function describeHookProposalDetails({
action,
data,
current,
workspace_name,
computers,
}: Params): ExtensionProposalDetail[] {
const computer = (id?: number | null) => (id ? `${computers.get(Number(id)) || "Computer"} (#${id})` : "");
if (action == "create") {
const watching = data.source == "browser_notifications";
return [
{ label: "Name", value: data.title || "" },
{
label: "Called by",
value: watching
? `Browser notifications on ${computer(data.watch_computer_id)}`
: "Anyone with its URL (shown to you once, when you confirm)",
},
...(watching ? [{ label: "Takes", value: data.watch_filter ? `Only notifications containing "${data.watch_filter}"` : "Every notification" }] : []),
{ label: "Runs in", value: workspace_name ? `The workspace ${workspace_name}` : "The bot's own chat with you" },
{
label: "Runs on",
value: computer(data.computer_id || (watching ? data.watch_computer_id : null)) || "The bot's current computer",
},
{ label: "Brief", value: data.instructions || "", block: true },
];
}
const lines: ExtensionProposalDetail[] = [
{ label: "Hook", value: `#${current?.id} ${current?.title || ""}`.trim() },
];
if (data.title !== undefined) {
lines.push({ label: "Name now", value: current?.title || "" }, { label: "New name", value: data.title });
}
if (data.instructions !== undefined) {
lines.push(
{ label: "Brief now", value: current?.instructions || "", block: true },
{ label: "New brief", value: data.instructions, block: true },
);
}
if (data.watch_filter !== undefined) {
lines.push(
{ label: "Filter now", value: current?.watch_filter || "None: every notification" },
{ label: "New filter", value: data.watch_filter || "None: every notification" },
);
}
if (data.enabled) lines.push({ label: "Switch on", value: "It's paused now; calls would run again" });
return lines;
}
+1
View File
@@ -81,6 +81,7 @@ function wordRefs({
.replace(/\[computer:(\d+)\](?!\()/g, "computer $1")
.replace(/\[take-over:(\d+):([a-z]+)\](?!\()/g, "(take over computer $1, $2)")
.replace(/\[proposal:(\d+)\](?!\()/g, "proposal $1")
.replace(/\[hook-proposal:(\d+)\](?!\()/g, "hook proposal $1")
.replace(/\[file:(\d+)\](?!\()/g, "file $1")
.replace(/\[folder:[a-z]+:\d+:([^\]\n]+)\](?!\()/g, "`$1`")
.replace(/\[folder:([a-z]+):(\d+)\](?!\()/g, "the $1 $2 folder");
+12
View File
@@ -0,0 +1,12 @@
type Params = {
id: number | string;
};
/**
* How a bot names a hook it proposed in a message: `[hook-proposal:4]`.
* The chat shows it as a chip that opens the card the user confirms it
* from.
*/
export default function hookProposalRef({ id }: Params) {
return `[hook-proposal:${id}]`;
}
+7
View File
@@ -3,6 +3,7 @@ import botRef from "./bot-ref";
import workspaceRef from "./workspace-ref";
import computerRef from "./computer-ref";
import linkComputerRefs from "./link-computer-refs";
import hookProposalRef from "./hook-proposal-ref";
import proposalRef from "./proposal-ref";
import takeOverRef from "./take-over-ref";
@@ -27,6 +28,12 @@ describe("linkComputerRefs", () => {
);
});
test("links hook proposals apart from extension proposals", () => {
expect(
linkComputerRefs({ text: `${hookProposalRef({ id: 4 })} and ${proposalRef({ id: 4 })}` }),
).toBe("[hook proposal 4](#hook-proposal-4) and [proposal 4](#proposal-4)");
});
test("links take-over requests", () => {
expect(
linkComputerRefs({
+13 -1
View File
@@ -44,11 +44,19 @@ export const FolderRefHrefPrefix = "#folder-";
*/
export const ProposalRefHrefPrefix = "#proposal-";
/**
* The link target a hook proposal ref (`hookProposalRef`) becomes, e.g.
* `#hook-proposal-4`. The chat renders it as a chip that opens the card
* where the user confirms or rejects the hook.
*/
export const HookProposalRefHrefPrefix = "#hook-proposal-";
/**
* Turns every computer ref (`[computer:3]`) and take-over ref
* (`[take-over:3:browser]`), bot ref (`[bot:3]`), workspace ref
* (`[workspace:2]`), file ref (`[file:12]`), folder ref
* (`[folder:computer:3:seo/raw]`) and proposal ref (`[proposal:7]`) in a message into a markdown link, so it
* (`[folder:computer:3:seo/raw]`), proposal ref (`[proposal:7]`) and hook
* proposal ref (`[hook-proposal:4]`) in a message into a markdown link, so it
* survives markdown rendering as a link the chat can swap for a button,
* chip or download.
*
@@ -75,6 +83,10 @@ export default function linkComputerRefs({ text }: { text: string }) {
/\[proposal:(\d+)\](?!\()/g,
(_, id: string) => `[proposal ${id}](${ProposalRefHrefPrefix}${id})`,
)
.replace(
/\[hook-proposal:(\d+)\](?!\()/g,
(_, id: string) => `[hook proposal ${id}](${HookProposalRefHrefPrefix}${id})`,
)
.replace(/\[file:(\d+)\](?!\()/g, (_, id: string) => `[file ${id}](${FileRefHrefPrefix}${id})`)
.replace(/\[folder:([a-z]+):(\d+):([^\]\n]+)\](?!\()/g, (_, volume: string, owner: string, path: string) =>
`[${path}](${FolderRefHrefPrefix}${encodeURIComponent(`${volume}:${owner}:${path}`)})`,