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,33 @@
|
||||
import { z } from "zod";
|
||||
import deleteBotHook from "@/src/functions/backend/db/bot-hooks/delete-bot-hook";
|
||||
import type { HarnessTool } from "../../../types";
|
||||
import grabOwnHook from "./grab-own-hook";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
};
|
||||
|
||||
type Args = {
|
||||
hook_id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets a bot delete one of its hooks for good.
|
||||
*/
|
||||
export default function deleteHookTool({ user_id, bot_id }: Params): HarnessTool<Args> {
|
||||
return {
|
||||
name: "delete_hook",
|
||||
description: `Delete one of your hooks for good (ids from list_hooks): its URL stops working and can't be brought back, and whatever calls it gets refused. Only when the user asks for it; to stop it for a while, pause_hook instead.`,
|
||||
schema: z.object({
|
||||
hook_id: z.number().int().positive(),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
const hook = await grabOwnHook({ user_id, bot_id, hook_id: args.hook_id });
|
||||
|
||||
await deleteBotHook({ user_id, hook_id: hook.id! });
|
||||
|
||||
return `Hook #${hook.id} "${hook.title}" deleted.`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { HookProposalView } from "@/src/types";
|
||||
import hookProposalRef from "@/src/utils/hook-proposal-ref";
|
||||
|
||||
type Params = {
|
||||
view: HookProposalView;
|
||||
};
|
||||
|
||||
/**
|
||||
* What a bot is told once it proposed a hook: the ref to put in its
|
||||
* reply, and what the card shows (worked out by the app), so it tells the
|
||||
* user the same thing the card does.
|
||||
*/
|
||||
export default function describeHookProposalForBot({ view }: Params) {
|
||||
const ref = hookProposalRef({ id: view.id });
|
||||
const shows = view.details
|
||||
.filter((detail) => !detail.block)
|
||||
.map((detail) => `${detail.label}: ${detail.value}`)
|
||||
.join("; ");
|
||||
|
||||
return [
|
||||
`Proposed as ${ref}. Nothing is saved yet: the user confirms or rejects it from the card ${ref} becomes in your reply, so put ${ref} in your reply.`,
|
||||
`The card shows ${shows}${view.details.some((detail) => detail.block) ? ", and the whole brief" : ""}.`,
|
||||
view.makes_url
|
||||
? `Once confirmed, the card shows the user its URL, once. You never see it: never ask for it. Tell them to give it to the service that will call it, and that they can require signed calls from the Hooks menu.`
|
||||
: "",
|
||||
`Don't say it's done: the user's next message says whether they confirmed it and what happened.`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import grabBotHookTools from "./grab-bot-hook-tools";
|
||||
|
||||
const names = (params: Partial<Parameters<typeof grabBotHookTools>[0]>) =>
|
||||
grabBotHookTools({ user_id: 1, bot_id: 2, computers: [], ...params }).map((tool) => tool.name);
|
||||
|
||||
describe("grabBotHookTools", () => {
|
||||
test("a hook call's own run gets no hook tools", () => {
|
||||
expect(names({ hooked: true, session_id: 3, onPropose: () => {} })).toEqual([]);
|
||||
});
|
||||
|
||||
test("a run without a chat to confirm in can't propose", () => {
|
||||
expect(names({ session_id: 3 })).toEqual(["list_hooks", "pause_hook", "delete_hook"]);
|
||||
});
|
||||
|
||||
test("a chat reply can propose", () => {
|
||||
expect(names({ session_id: 3, onPropose: () => {} })).toContain("propose_hook");
|
||||
});
|
||||
|
||||
test("a proposal never takes a signing secret", () => {
|
||||
const propose = grabBotHookTools({
|
||||
user_id: 1,
|
||||
bot_id: 2,
|
||||
computers: [],
|
||||
session_id: 3,
|
||||
onPropose: () => {},
|
||||
}).find((tool) => tool.name == "propose_hook")!;
|
||||
const parsed = propose.schema!.parse({
|
||||
title: "GitHub pushes",
|
||||
instructions: "Summarise each push.",
|
||||
signing_secret: "abcdefgh",
|
||||
reason: "The user asked for push summaries.",
|
||||
});
|
||||
|
||||
expect(parsed).not.toHaveProperty("signing_secret");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
|
||||
import type { HarnessTool } from "../../../types";
|
||||
import deleteHookTool from "./delete-hook-tool";
|
||||
import listHooksTool from "./list-hooks-tool";
|
||||
import pauseHookTool from "./pause-hook-tool";
|
||||
import proposeHookTool from "./propose-hook-tool";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
session_id?: number | string;
|
||||
workspace_id?: number | string | null;
|
||||
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
|
||||
/**
|
||||
* The run is a hook call: what started it came from outside, so it
|
||||
* gets no hook tools at all.
|
||||
*/
|
||||
hooked?: boolean;
|
||||
/**
|
||||
* Called with each proposal's ref, so the reply carries its card. Only
|
||||
* a chat reply proposes: someone is there to confirm.
|
||||
*/
|
||||
onPropose?: (params: { ref: string }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A bot's tools for the user's hooks it runs: read them, pause or delete
|
||||
* one (that only takes away), and in a chat reply propose a new one or a
|
||||
* change (the user confirms it from a card; a new one's URL goes to them
|
||||
* alone). A hook call's own run gets none, so a caller's payload can never
|
||||
* reach them.
|
||||
*/
|
||||
export default function grabBotHookTools({
|
||||
user_id,
|
||||
bot_id,
|
||||
session_id,
|
||||
workspace_id,
|
||||
computers,
|
||||
hooked,
|
||||
onPropose,
|
||||
}: Params): HarnessTool[] {
|
||||
if (hooked) return [];
|
||||
|
||||
return [
|
||||
listHooksTool({ user_id, bot_id }),
|
||||
pauseHookTool({ user_id, bot_id }),
|
||||
deleteHookTool({ user_id, bot_id }),
|
||||
...(session_id && onPropose
|
||||
? [proposeHookTool({ user_id, bot_id, session_id, workspace_id, computers, onPropose })]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import grabUserBotHook from "@/src/functions/backend/db/bot-hooks/grab-user-bot-hook";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
hook_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* One of the user's hooks that this bot runs, or an `AppError`: a bot
|
||||
* never touches another bot's hooks.
|
||||
*/
|
||||
export default async function grabOwnHook({ user_id, bot_id, hook_id }: Params) {
|
||||
const hook = await grabUserBotHook({ user_id, hook_id });
|
||||
|
||||
if (String(hook.bot_id) != String(bot_id)) {
|
||||
throw new AppError(`Hook #${hook_id} isn't one of yours.`);
|
||||
}
|
||||
|
||||
return hook;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from "zod";
|
||||
import grabBotHooks from "@/src/functions/backend/db/bot-hooks/grab-bot-hooks";
|
||||
import describeBotHook from "@/src/utils/describe-bot-hook";
|
||||
import type { HarnessTool } from "../../../types";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets a bot see the user's hooks it runs, everywhere it works, never
|
||||
* their URLs.
|
||||
*/
|
||||
export default function listHooksTool({ user_id, bot_id }: Params): HarnessTool<Record<string, never>> {
|
||||
return {
|
||||
name: "list_hooks",
|
||||
description: `List the user's hooks that give you work: id, name, what calls it (an outside service at its secret URL, or browser notifications on a computer), where it runs, whether it's paused, its runs, last call and last error. You never see a hook's URL: only the user does.`,
|
||||
schema: z.object({}),
|
||||
run: async () => {
|
||||
const hooks = await grabBotHooks({ user_id, bot_id });
|
||||
|
||||
if (!hooks.length) return `You have no hooks.`;
|
||||
|
||||
return hooks.map((hook) => `- ${describeBotHook({ hook })}`).join("\n");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { z } from "zod";
|
||||
import updateBotHook from "@/src/functions/backend/db/bot-hooks/update-bot-hook";
|
||||
import describeBotHook from "@/src/utils/describe-bot-hook";
|
||||
import type { HarnessTool } from "../../../types";
|
||||
import grabOwnHook from "./grab-own-hook";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
};
|
||||
|
||||
type Args = {
|
||||
hook_id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets a bot pause one of its hooks: calls are refused until it's on
|
||||
* again. Only taking away needs no confirming; switching it back on is a
|
||||
* proposal (`propose_hook`).
|
||||
*/
|
||||
export default function pauseHookTool({ user_id, bot_id }: Params): HarnessTool<Args> {
|
||||
return {
|
||||
name: "pause_hook",
|
||||
description: `Pause one of your hooks (ids from list_hooks): calls to it are refused until it's switched back on. Use it when the user asks, or when a hook keeps sending work that fails or that the user doesn't want. Switching it back on needs the user's confirmation: propose it with propose_hook (hook_id, enabled true).`,
|
||||
schema: z.object({
|
||||
hook_id: z.number().int().positive(),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
const hook = await grabOwnHook({ user_id, bot_id, hook_id: args.hook_id });
|
||||
|
||||
if (!hook.enabled) return `Hook #${hook.id} "${hook.title}" is already paused.`;
|
||||
|
||||
const paused = await updateBotHook({ user_id, hook_id: hook.id!, data: { enabled: false } });
|
||||
|
||||
if (paused.enabled) throw new Error(`Hook #${hook.id} is still on: pausing it didn't take.`);
|
||||
|
||||
return `Hook paused: ${describeBotHook({ hook: paused })}.`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { z } from "zod";
|
||||
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
|
||||
import { BotHookSources, type BotHookSource } from "@/src/dict/bot-hook-dict";
|
||||
import createHookProposal from "@/src/functions/backend/db/hook-proposals/create-hook-proposal";
|
||||
import grabHookProposalView from "@/src/functions/backend/db/hook-proposals/grab-hook-proposal-view";
|
||||
import hookProposalRef from "@/src/utils/hook-proposal-ref";
|
||||
import type { HarnessTool } from "../../../types";
|
||||
import describeHookProposalForBot from "./describe-hook-proposal-for-bot";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* The conversation it's proposed in, where its card shows.
|
||||
*/
|
||||
session_id: number | string;
|
||||
/**
|
||||
* Where the run is: a new hook runs there too.
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
/**
|
||||
* The bot's computers where it runs, the current one first.
|
||||
*/
|
||||
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
|
||||
/**
|
||||
* Called with the proposal's ref, so the reply always carries its card.
|
||||
*/
|
||||
onPropose: (params: { ref: string }) => void;
|
||||
};
|
||||
|
||||
type Args = {
|
||||
hook_id?: number;
|
||||
title?: string;
|
||||
instructions?: string;
|
||||
source?: BotHookSource;
|
||||
computer_id?: number;
|
||||
watch_computer_id?: number;
|
||||
watch_filter?: string;
|
||||
enabled?: true;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets a bot propose a hook, or a change to one of its own (name, brief,
|
||||
* filter, switching it back on), for the user to confirm from a card in
|
||||
* the chat. A hook runs its brief on its own whenever something outside
|
||||
* calls it, so the user writes it off, never the bot: nothing is saved
|
||||
* until they confirm, and a new hook's URL is shown to them alone.
|
||||
*/
|
||||
export default function proposeHookTool({
|
||||
user_id,
|
||||
bot_id,
|
||||
session_id,
|
||||
workspace_id,
|
||||
computers,
|
||||
onPropose,
|
||||
}: Params): HarnessTool<Args> {
|
||||
const listed = computers.map((computer) => `${computer.id} (${computer.name || computer.kind})`).join(", ");
|
||||
|
||||
return {
|
||||
name: "propose_hook",
|
||||
description: `Propose a hook for the user to confirm: a secret URL an outside service (a webhook from GitHub, Stripe, a form, a script) calls to give you a task, or a watcher that hands you each browser notification on one of your computers (source browser_notifications, with watch_computer_id and an optional watch_filter, text the notification's site, title or body must contain). Every call queues your instructions (the brief) as a task for you, ${
|
||||
workspace_id ? "in this workspace" : "in your own chat"
|
||||
}, with what the caller sent attached as data, and the result is posted here. Runs carry the brief out without asking the user, so write it as a full brief for a future you with no memory of this chat, saying exactly what to do with each call. ${
|
||||
computers.length ? `Your computers here: ${listed}.` : `You have no computer here, so it can't watch one.`
|
||||
} To change one of your hooks (ids from list_hooks), give hook_id and only what changes: title, instructions, watch_filter, or enabled true to switch a paused one back on; what calls it and where it runs can't change. Nothing is saved until the user presses Confirm on the card. A new hook's URL is shown to the user on the card, once: you never see it, never ask for it, and never set a signing secret (the user adds one from the Hooks menu). Check list_hooks first: if a hook already does the job, propose a change to it instead.`,
|
||||
schema: z.object({
|
||||
hook_id: z.number().int().positive().optional().describe("The hook to change; leave out for a new one"),
|
||||
title: z.string().trim().min(1).max(80).optional().describe(`A short name, e.g. "GitHub pushes"`),
|
||||
instructions: z.string().trim().min(1).max(8000).optional().describe("The brief every call runs"),
|
||||
source: z.enum(BotHookSources.map((s) => s.value) as [BotHookSource, ...BotHookSource[]]).optional(),
|
||||
computer_id: z.number().int().positive().optional().describe("The computer its runs use; leave out for your current one"),
|
||||
watch_computer_id: z.number().int().positive().optional(),
|
||||
watch_filter: z.string().trim().max(300).optional(),
|
||||
enabled: z.literal(true).optional(),
|
||||
reason: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(10, "Say why you propose it")
|
||||
.max(2000)
|
||||
.describe("Why you propose it and what the user asked for, shown on the card as your words"),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
const { hook_id, reason, ...data } = args;
|
||||
|
||||
const id = await createHookProposal({
|
||||
user_id,
|
||||
bot_id,
|
||||
session_id,
|
||||
workspace_id,
|
||||
hook_id,
|
||||
data,
|
||||
reason,
|
||||
});
|
||||
|
||||
onPropose({ ref: hookProposalRef({ id }) });
|
||||
|
||||
return describeHookProposalForBot({ view: await grabHookProposalView({ proposal_id: id, user_id }) });
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user