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:
@@ -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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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}]`;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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}`)})`,
|
||||
|
||||
Reference in New Issue
Block a user