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,19 @@
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import type { HookProposalDecision } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
proposal_id: number;
|
||||
decision: "confirm" | "reject";
|
||||
};
|
||||
|
||||
/**
|
||||
* Confirms or rejects a hook proposal from its card, returning it as it
|
||||
* now stands and, for a new hook called at a URL, its token (shown once).
|
||||
*/
|
||||
export default async function decideHookProposal({ proposal_id, decision }: Params) {
|
||||
return await fetchApi<{ proposal_id: number }, APIResponseObject<HookProposalDecision>>(
|
||||
`/api/admin/hook-proposals/${decision}-proposal`,
|
||||
{ method: "POST", body: { proposal_id } },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { HookProposalView } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
proposal_id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook proposals already read, by id, so a thread naming one twice (or a
|
||||
* re-render) asks once. A decision replaces its entry.
|
||||
*/
|
||||
const loaded = new Map<number, Promise<HookProposalView | null>>();
|
||||
|
||||
function loadProposal({ proposal_id }: Params) {
|
||||
const cached = loaded.get(proposal_id);
|
||||
|
||||
if (cached) return cached;
|
||||
|
||||
const pending = fetchApi<{}, APIResponseObject<HookProposalView>>(
|
||||
`/api/admin/hook-proposals/get-proposal?proposal_id=${proposal_id}`,
|
||||
{ method: "GET" },
|
||||
)
|
||||
.then((res) => (res?.success && res.singleRes ? res.singleRes : null))
|
||||
.catch(() => null);
|
||||
|
||||
loaded.set(proposal_id, pending);
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* A hook a bot proposed (`[hook-proposal:ID]`), for its chip and confirm
|
||||
* card: read when the chip renders, so it shows whether it's still
|
||||
* waiting. Only the user it was proposed to can read it; anyone else gets
|
||||
* null.
|
||||
*/
|
||||
export default function useHookProposal({ proposal_id }: Params) {
|
||||
const [view, setViewState] = useState<HookProposalView | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
|
||||
loadProposal({ proposal_id }).then((result) => {
|
||||
if (!live) return;
|
||||
setViewState(result);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [proposal_id]);
|
||||
|
||||
const setView = useCallback(
|
||||
(next: HookProposalView) => {
|
||||
loaded.set(proposal_id, Promise.resolve(next));
|
||||
setViewState(next);
|
||||
},
|
||||
[proposal_id],
|
||||
);
|
||||
|
||||
return { view, loading, setView };
|
||||
}
|
||||
@@ -42,7 +42,8 @@ type Props = {
|
||||
* (a webhook, a script) calls to give the bot a task, whose result lands
|
||||
* here. Open it from the header menu; the list loads then, so the page's
|
||||
* props stay small. The user makes, pauses, edits, re-keys and deletes
|
||||
* their own; bots never make hooks.
|
||||
* their own; bots propose new hooks and changes from the chat
|
||||
* (`propose_hook`), which the user confirms there.
|
||||
*/
|
||||
export default function BotHooksModal({ bot, workspace, on_open }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import type { HookProposalView } from "@/src/types";
|
||||
import HookProposalCard from "./hook-proposal-card";
|
||||
|
||||
const pushes: HookProposalView = {
|
||||
id: 4,
|
||||
action: "create",
|
||||
hook_id: null,
|
||||
title: "GitHub pushes",
|
||||
bot_id: 6,
|
||||
bot_name: "Ada",
|
||||
status: "pending",
|
||||
expired: false,
|
||||
expires_at: Date.now() + 60 * 60 * 1000,
|
||||
outcome: "",
|
||||
result_id: null,
|
||||
makes_url: true,
|
||||
details: [
|
||||
{ label: "Called by", value: "Anyone with its URL (shown to you once, when you confirm)" },
|
||||
{ label: "Brief", value: "Summarise each push.", block: true },
|
||||
],
|
||||
reason: "You asked for a summary of every push.",
|
||||
};
|
||||
|
||||
function render({ view }: { view: HookProposalView }) {
|
||||
return renderToString(<HookProposalCard view={view} onDecided={() => {}} onDone={() => {}} />);
|
||||
}
|
||||
|
||||
describe("HookProposalCard", () => {
|
||||
test("shows what would be saved, the bot's reason and the buttons", () => {
|
||||
const html = render({ view: pushes });
|
||||
|
||||
expect(html).toContain("Summarise each push.");
|
||||
expect(html).toContain("Why (written by the bot)");
|
||||
expect(html).toContain("You asked for a summary of every push.");
|
||||
expect(html).toContain("Its URL is shown here once you confirm");
|
||||
expect(html).toContain(">Confirm<");
|
||||
});
|
||||
|
||||
test("offers no buttons once it's decided or expired", () => {
|
||||
expect(render({ view: { ...pushes, status: "confirmed", outcome: "Saved as hook #2." } })).not.toContain(">Confirm<");
|
||||
expect(render({ view: { ...pushes, expired: true } })).not.toContain(">Confirm<");
|
||||
});
|
||||
|
||||
test("a change says nothing about a URL", () => {
|
||||
expect(render({ view: { ...pushes, action: "change", hook_id: 2, makes_url: false } })).not.toContain("Its URL");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useContext, useState } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import HookUrlNotice from "@/src/components/general/hook-url-notice";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import type { HookProposalView } from "@/src/types";
|
||||
import decideHookProposal from "../(functions)/decide-hook-proposal";
|
||||
import sendBotMessage from "../(functions)/send-bot-message";
|
||||
import hookProposalDecisionMessage from "../(utils)/hook-proposal-decision-message";
|
||||
import proposalExpiryLabel from "../(utils)/proposal-expiry-label";
|
||||
import MessageDetailsHeading from "./message-details-heading";
|
||||
import ProposalDetailRow from "./proposal-detail-row";
|
||||
|
||||
type Props = {
|
||||
view: HookProposalView;
|
||||
onDecided: (view: HookProposalView) => void;
|
||||
/**
|
||||
* The user is done with the card (after deciding, or after copying a
|
||||
* new hook's URL).
|
||||
*/
|
||||
onDone: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The confirm card for a hook a bot proposed. Everything above the bot's
|
||||
* reason is read from what Confirm saves (what calls it, where it runs,
|
||||
* the whole brief; for a change each field as it is and would be).
|
||||
* Confirming a new hook called at a URL shows that URL here, once: the
|
||||
* bot never gets it. Deciding tells the bot in chat what happened.
|
||||
*/
|
||||
export default function HookProposalCard({ view, onDecided, onDone }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const [busy, setBusy] = useState<"confirm" | "reject">();
|
||||
const [error, setError] = useState<string>();
|
||||
const [token, setToken] = useState<string>();
|
||||
|
||||
const open = view.status == "pending" && !view.expired;
|
||||
|
||||
const decide = async (decision: "confirm" | "reject") => {
|
||||
const question =
|
||||
decision == "reject"
|
||||
? `Reject this hook? Nothing will be saved.`
|
||||
: view.action == "create"
|
||||
? `Add the hook ${view.title}? Each call runs its brief for ${view.bot_name} on its own, as you.`
|
||||
: `Change the hook ${view.title}?`;
|
||||
|
||||
if (!window.confirm(question)) return;
|
||||
|
||||
setBusy(decision);
|
||||
setError(undefined);
|
||||
|
||||
const res = await decideHookProposal({ proposal_id: view.id, decision }).catch(() => null);
|
||||
|
||||
if (!res?.success || !res.singleRes) {
|
||||
setBusy(undefined);
|
||||
setError(res?.msg || `Couldn't ${decision} it.`);
|
||||
return;
|
||||
}
|
||||
|
||||
setToken(res.singleRes.token);
|
||||
onDecided(res.singleRes.view);
|
||||
|
||||
await sendBotMessage({
|
||||
bot_id: Number(pageProps?.bot?.id) || 0,
|
||||
workspace_id: pageProps?.workspace?.id || null,
|
||||
text: hookProposalDecisionMessage({ view: res.singleRes.view }),
|
||||
media_ids: [],
|
||||
}).catch(() => null);
|
||||
|
||||
setBusy(undefined);
|
||||
|
||||
if (!res.singleRes.token) onDone();
|
||||
};
|
||||
|
||||
if (token) return <HookUrlNotice token={token} onDone={onDone} />;
|
||||
|
||||
return (
|
||||
<Stack className="w-full gap-0! items-stretch">
|
||||
<MessageDetailsHeading>What would be saved</MessageDetailsHeading>
|
||||
{view.details.map((detail) => (
|
||||
<ProposalDetailRow key={detail.label} {...detail} />
|
||||
))}
|
||||
{view.makes_url ? (
|
||||
<Span className="mt-1 text-[12px] leading-4 text-ink-secondary">
|
||||
{`Its URL is shown here once you confirm, to you only. To require signed calls, add a signing secret from the Hooks menu afterwards.`}
|
||||
</Span>
|
||||
) : null}
|
||||
|
||||
<MessageDetailsHeading>Why (written by the bot)</MessageDetailsHeading>
|
||||
<Span className="whitespace-pre-wrap text-[13px] leading-5 text-ink break-words">{view.reason}</Span>
|
||||
|
||||
{view.outcome ? <Span className="mt-4 text-[13px] leading-5 text-ink">{view.outcome}</Span> : null}
|
||||
|
||||
{error ? <Span className="mt-2 text-[13px] leading-5 text-ink">{error}</Span> : null}
|
||||
|
||||
{open ? (
|
||||
<Row className="mt-5 w-full gap-2! justify-end">
|
||||
<Span className="grow text-[12px] leading-4 text-ink-secondary">
|
||||
{proposalExpiryLabel({ expires_at: view.expires_at })}
|
||||
</Span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
size="small"
|
||||
title={`Reject the hook ${view.title}: nothing is saved`}
|
||||
loading={busy == "reject"}
|
||||
disabled={Boolean(busy)}
|
||||
onClick={() => decide("reject")}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
title={`Save the hook ${view.title}`}
|
||||
loading={busy == "confirm"}
|
||||
disabled={Boolean(busy)}
|
||||
onClick={() => decide("confirm")}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</Row>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState } from "react";
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { Webhook } from "lucide-react";
|
||||
import useHookProposal from "../(hooks)/use-hook-proposal";
|
||||
import hookProposalTitle from "../(utils)/hook-proposal-title";
|
||||
import proposalStatusLabel from "../(utils)/proposal-status-label";
|
||||
import HookProposalCard from "./hook-proposal-card";
|
||||
|
||||
type Props = {
|
||||
proposal_id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A hook a bot proposed (`hookProposalRef`): a chip naming it and where
|
||||
* it stands, which opens the card the user confirms or rejects it from.
|
||||
* Anyone it wasn't proposed to sees its number only.
|
||||
*/
|
||||
export default function HookProposalRefChip({ proposal_id }: Props) {
|
||||
const { view, loading, setView } = useHookProposal({ proposal_id });
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!view) {
|
||||
return (
|
||||
<Span className="font-bold">
|
||||
{loading ? `hook proposal ${proposal_id}` : `hook proposal ${proposal_id} (not available)`}
|
||||
</Span>
|
||||
);
|
||||
}
|
||||
|
||||
const title = hookProposalTitle({ view });
|
||||
const status = proposalStatusLabel({ view });
|
||||
|
||||
return (
|
||||
<Modal
|
||||
target={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
size="smaller"
|
||||
title={`Review: ${title}`}
|
||||
className="inline-flex h-6 gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap"
|
||||
>
|
||||
<Webhook size={14} aria-hidden className="shrink-0" />
|
||||
{`${title} · ${status}`}
|
||||
</Button>
|
||||
}
|
||||
targetWrapperProps={{ className: "inline-flex align-middle" }}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
className="max-w-xl bg-paper p-6"
|
||||
>
|
||||
<Stack className="w-full gap-1! items-stretch">
|
||||
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">{title}</H2>
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
{`${status}. ${view.bot_name} proposed this; nothing is saved until you confirm. Everything above the bot's own words is read from what Confirm would save.`}
|
||||
</Span>
|
||||
</Stack>
|
||||
|
||||
<Stack className="mt-2 max-h-[65vh] w-full gap-0! items-stretch overflow-y-auto">
|
||||
<HookProposalCard view={view} onDecided={setView} onDone={() => setOpen(false)} />
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
BotRefHrefPrefix,
|
||||
ComputerRefHrefPrefix,
|
||||
FileRefHrefPrefix,
|
||||
HookProposalRefHrefPrefix,
|
||||
ProposalRefHrefPrefix,
|
||||
TakeOverRefHrefPrefix,
|
||||
WorkspaceRefHrefPrefix,
|
||||
@@ -14,6 +15,7 @@ import BotRefChip from "./bot-ref-chip";
|
||||
import ComputerRefChip from "./computer-ref-chip";
|
||||
import FileRefChip from "./file-ref-chip";
|
||||
import FolderRefChip from "./folder-ref-chip";
|
||||
import HookProposalRefChip from "./hook-proposal-ref-chip";
|
||||
import ProposalRefChip from "./proposal-ref-chip";
|
||||
import TakeOverChip from "./take-over-chip";
|
||||
import WorkspaceRefChip from "./workspace-ref-chip";
|
||||
@@ -23,9 +25,9 @@ import WorkspaceRefChip from "./workspace-ref-chip";
|
||||
* becomes its switch button, a take-over request a button that opens the
|
||||
* computer, a bot it named a link to that bot's conversation, a workspace
|
||||
* a link to that workspace, a file it saved a chip that downloads it, a
|
||||
* folder it named a chip that opens it in the files browser, an MCP server
|
||||
* or tool it proposed a chip that opens the confirm card; anything else
|
||||
* opens in a new tab.
|
||||
* folder it named a chip that opens it in the files browser, an MCP server,
|
||||
* tool, skill or hook it proposed a chip that opens the confirm card;
|
||||
* anything else opens in a new tab.
|
||||
*/
|
||||
export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
|
||||
if (href?.startsWith(ComputerRefHrefPrefix)) {
|
||||
@@ -73,6 +75,12 @@ export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
|
||||
if (proposal_id) return <ProposalRefChip proposal_id={proposal_id} />;
|
||||
}
|
||||
|
||||
if (href?.startsWith(HookProposalRefHrefPrefix)) {
|
||||
const proposal_id = Number(href.slice(HookProposalRefHrefPrefix.length));
|
||||
|
||||
if (proposal_id) return <HookProposalRefChip proposal_id={proposal_id} />;
|
||||
}
|
||||
|
||||
const folder_ref = parseFolderRefHref({ href });
|
||||
|
||||
if (folder_ref) return <FolderRefChip spec={folder_ref.spec} path={folder_ref.path} />;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { HookProposalView } from "@/src/types";
|
||||
import hookProposalTitle from "./hook-proposal-title";
|
||||
|
||||
type Params = {
|
||||
view: HookProposalView;
|
||||
};
|
||||
|
||||
/**
|
||||
* What the chat tells the bot once the user decided on its hook proposal,
|
||||
* so it carries on knowing what happened. Never the hook's URL: the
|
||||
* outcome doesn't hold it.
|
||||
*/
|
||||
export default function hookProposalDecisionMessage({ view }: Params) {
|
||||
const title = hookProposalTitle({ view });
|
||||
|
||||
if (view.status == "rejected") return `I rejected hook proposal #${view.id} (${title}). Nothing was saved.`;
|
||||
|
||||
if (view.status == "failed") return `I confirmed hook proposal #${view.id} (${title}), but it failed. ${view.outcome}`;
|
||||
|
||||
return `I confirmed hook proposal #${view.id} (${title}). ${view.outcome}`;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { HookProposalView } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
view: Pick<HookProposalView, "action" | "title">;
|
||||
};
|
||||
|
||||
/**
|
||||
* What a hook proposal does, e.g. `Add hook GitHub pushes` or `Change
|
||||
* hook GitHub pushes`.
|
||||
*/
|
||||
export default function hookProposalTitle({ view }: Params) {
|
||||
return `${view.action == "change" ? "Change" : "Add"} hook ${view.title}`;
|
||||
}
|
||||
Reference in New Issue
Block a user