From fb4c89fca115f2180dd290f1774618ed55dab2a2 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Sun, 25 Oct 2026 17:08:12 +0100 Subject: [PATCH] Tie each hook to a session, picked in the hook form A hook's calls used to run in whatever session was active at the time. Now `bot_hooks.session_id` pins them to one session, open or closed (null still follows the active one). The hook form picks where it runs (the bot's own chat or one of its workspaces) and a session there, or a new one of its own started beside the active one so the chat on screen stays. A bot's proposed hook gets the session it was proposed in, and a missing session is replaced on the next call. Changing place or session is refused while a run is open; the computer stays only if reachable. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- db/schema.ts | 5 + db/types/db.ts | 4 + src/components/general/hook-form.tsx | 42 +++++++- src/components/general/hook-place-fields.tsx | 102 ++++++++++++++++++ .../backend/db/bot-hooks/create-bot-hook.ts | 33 +++++- .../backend/db/bot-hooks/fire-bot-hook.ts | 11 +- .../backend/db/bot-hooks/grab-bot-hooks.ts | 2 + .../backend/db/bot-hooks/grab-hook-places.ts | 34 ++++++ .../backend/db/bot-hooks/note-hook-session.ts | 25 +++++ .../backend/db/bot-hooks/pick-hook-session.ts | 53 +++++++++ .../db/bot-hooks/resolve-hook-session.ts | 40 +++++++ .../db/bot-hooks/start-hook-session.ts | 29 +++++ .../backend/db/bot-hooks/update-bot-hook.ts | 76 ++++++++++++- .../db/bot-sessions/create-bot-session.ts | 15 ++- .../hook-proposals/confirm-hook-proposal.ts | 5 +- .../tools/bot/hooks/propose-hook-tool.ts | 2 +- .../[bot_id]/(partials)/bot-hooks-modal.tsx | 12 ++- src/pages/api/admin/hooks/hook-places.ts | 36 +++++++ src/pages/api/admin/hooks/update-hook.ts | 2 +- src/schemas/bot-hook-schema.ts | 17 +++ src/types/index.ts | 21 ++++ src/utils/hook-move-computers.test.ts | 36 +++++++ src/utils/hook-move-computers.ts | 35 ++++++ src/utils/hook-subtitle.test.ts | 6 ++ src/utils/hook-subtitle.ts | 4 +- 26 files changed, 630 insertions(+), 19 deletions(-) create mode 100644 src/components/general/hook-place-fields.tsx create mode 100644 src/functions/backend/db/bot-hooks/grab-hook-places.ts create mode 100644 src/functions/backend/db/bot-hooks/note-hook-session.ts create mode 100644 src/functions/backend/db/bot-hooks/pick-hook-session.ts create mode 100644 src/functions/backend/db/bot-hooks/resolve-hook-session.ts create mode 100644 src/functions/backend/db/bot-hooks/start-hook-session.ts create mode 100644 src/pages/api/admin/hooks/hook-places.ts create mode 100644 src/utils/hook-move-computers.test.ts create mode 100644 src/utils/hook-move-computers.ts diff --git a/AGENTS.md b/AGENTS.md index 0f227f0..1647a8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -141,7 +141,7 @@ Server push only. `pushBotNotice` (`server:bot-message`, `server:bot-updated`), ### Hooks (`bot_hooks`) -A secret URL, `POST /api/hooks/` (`src/pages/api/hooks/[token].ts`, public, no session), that queues a hook's brief plus the raw body as a task, per user and bot like a routine (runs act as the owner, who must still reach the bot and not be read-only). Only the token's prefix and SHA-256 are stored (shown once; "New URL" rotates it); an optional HMAC-SHA256 signing secret is stored encrypted (`verifyHookSignature`). Order in the route: rate limit per IP, lookup (same 404 for every miss), rate limit per hook, signature, then `fireBotHook`. Calls are recorded in `bot_hook_events` (pruned daily) with idempotency on the sender's delivery header. A call while the last run is still pending and untried joins its brief (`mergeHookCallSql`) instead of queueing another. The payload is fenced data (`hookPayloadBlock`), and `HookRunNote` tells the bot never to take instructions from it. Failed runs retry like routines and keep why in `last_error`. Users make hooks in the conversation's Hooks modal and manage all of theirs on `/admin/routines`. **A bot never makes or changes a hook on its own:** `list_hooks`, `pause_hook` and `delete_hook` act directly (they only take away); a new hook, or a new name, brief or filter, or switching one back on, goes through `propose_hook` -> `hook_proposals` -> `[hook-proposal:ID]` card, described from the data and confirmed by that user (`checkHookProposal` runs again on confirm). What calls a hook, its computers and its signing are never proposed. A new hook's URL shows on the card only, never in the outcome the bot is told. Only chat replies propose; a hook call's own run gets no hook tools. +A secret URL, `POST /api/hooks/` (`src/pages/api/hooks/[token].ts`, public, no session), that queues a hook's brief plus the raw body as a task, per user and bot like a routine (runs act as the owner, who must still reach the bot and not be read-only). Only the token's prefix and SHA-256 are stored (shown once; "New URL" rotates it); an optional HMAC-SHA256 signing secret is stored encrypted (`verifyHookSignature`). Order in the route: rate limit per IP, lookup (same 404 for every miss), rate limit per hook, signature, then `fireBotHook`. Calls are recorded in `bot_hook_events` (pruned daily) with idempotency on the sender's delivery header. A call while the last run is still pending and untried joins its brief (`mergeHookCallSql`) instead of queueing another. The payload is fenced data (`hookPayloadBlock`), and `HookRunNote` tells the bot never to take instructions from it. Failed runs retry like routines and keep why in `last_error`. Users make hooks in the conversation's Hooks modal and manage all of theirs on `/admin/routines`. A hook runs in one session (`bot_hooks.session_id`, open or closed; null follows the active session): the form picks the place (the bot's own chat or one of its workspaces) and a session there, or a new one of its own, made closed beside the active one (`startHookSession`) so the chat on screen stays; a bot's proposed hook gets the session it was proposed in; a missing session is replaced on the next call (`resolveHookSession`). Changing place or session is refused while a run is open; the computer is kept only if reachable there. **A bot never makes or changes a hook on its own:** `list_hooks`, `pause_hook` and `delete_hook` act directly (they only take away); a new hook, or a new name, brief or filter, or switching one back on, goes through `propose_hook` -> `hook_proposals` -> `[hook-proposal:ID]` card, described from the data and confirmed by that user (`checkHookProposal` runs again on confirm). What calls a hook, its computers and its signing are never proposed. A new hook's URL shows on the card only, never in the outcome the bot is told. Only chat replies propose; a hook call's own run gets no hook tools. **Browser notifications:** a hook with `source = browser_notifications` is called by a watcher on `watch_computer_id` instead of an outside service. The watcher (`src/data/notify-watch-script.ts`, Python + GLib, run as `gem` from `~/.cobble/`) owns `org.freedesktop.Notifications` on the sandbox's session bus, so Chrome hands it every notification, push ones included, instead of its silent built-in message center, and it posts each as JSON (`origin`, `title`, `body`) to the URLs in `~/.cobble/notify-hooks`. Chrome only looks for that service when it starts, so `syncComputerNotifyWatch` restarts Chrome once after starting the watcher (its sandbox supervisor brings it back), never while a task works on that computer. The hook's token is kept encrypted (`watch_token`) to rewrite that list after a container is recreated; `sync-notify-watchers` (every 5 minutes) and every change to such a hook resync it. `watch_filter` keeps only matching notifications (others are recorded as `ignored`). Computers reach the app at `COMPUTER_APP_URL` (an app setting; needed for a dev server outside docker), else the app container's name on the docker network. Sites still need notification permission in that computer's browser (a `Browser.grantPermissions` over CDP lasts only for that CDP session). Anything on the computer can read the list and call those hooks, which is one more reason payloads are only data. diff --git a/db/schema.ts b/db/schema.ts index 1aa8f96..a9fb65b 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -1186,6 +1186,11 @@ const schema: BUN_SQLITE_DatabaseSchemaType = { foreignKeyName: "bot_hooks_workspace_fk", }, }, + { + fieldName: "session_id", + fieldDescription: `The session (of this user, bot and place) its runs and results land in, open or closed; null follows the conversation's active session. A bot's proposed hook gets the session it was proposed in. Not a foreign key: a missing one is replaced by a new session on the next call`, + dataType: "INTEGER", + }, { fieldName: "title", dataType: "TEXT", diff --git a/db/types/db.ts b/db/types/db.ts index 79b5549..6a9f74a 100644 --- a/db/types/db.ts +++ b/db/types/db.ts @@ -742,6 +742,10 @@ export type BUN_SQLITE_COBBLE_BOT_HOOKS = { * The workspace it runs in (context, computers, models, and the conversation its results go to); null for the bot's own chat */ workspace_id?: number | ""; + /** + * The session (of this user, bot and place) its runs and results land in, open or closed; null follows the conversation's active session. A bot's proposed hook gets the session it was proposed in. Not a foreign key: a missing one is replaced by a new session on the next call + */ + session_id?: number | ""; title?: string; /** * The brief each run gets (markdown); the call's payload is added below it as data diff --git a/src/components/general/hook-form.tsx b/src/components/general/hook-form.tsx index a706e15..d020095 100644 --- a/src/components/general/hook-form.tsx +++ b/src/components/general/hook-form.tsx @@ -4,6 +4,7 @@ import Form from "@moduletrace/twui/form/Form"; import Input from "@moduletrace/twui/form/Input"; import Select from "@moduletrace/twui/form/Select"; import CodeEditor from "@/src/components/general/code-editor"; +import HookPlaceFields from "@/src/components/general/hook-place-fields"; import { BotHookSignatureFormats, BotHookSources, @@ -15,6 +16,7 @@ import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions"; import type { BotHookWithNames, BotHookWithToken, + HookSessionChoice, NotifyWatchSync, } from "@/src/types"; @@ -24,6 +26,10 @@ type Props = { */ hook?: BotHookWithNames; bot_id: number; + /** + * Where a new hook runs at first (the conversation it's made from); + * the form can pick another place, and another session there. + */ workspace_id?: number | null; /** * The bot's computers there, for a new browser-notification hook. Left @@ -45,7 +51,8 @@ type Props = { }; /** - * A hook's name and brief, what calls it (its URL, or a computer's browser + * A hook's name and brief, where it runs (the bot's own chat or one of + * its workspaces, and which session there), what calls it (its URL, or a computer's browser * notifications, chosen once when it's made), and then either whether its * calls must be signed (the secret, the header it comes in and how it's * written) or which notifications it takes. Makes a hook or edits one; a @@ -78,6 +85,19 @@ export default function HookForm({ const [format, setFormat] = useState( (hook?.signature_format as BotHookSignatureFormat) || "sha256=hex", ); + const [place, setPlace] = useState<{ + workspace_id: number | null; + session: HookSessionChoice; + }>({ + workspace_id: hook + ? Number(hook.workspace_id) || null + : workspace_id || null, + session: hook + ? hook.session_id + ? Number(hook.session_id) + : "current" + : "new", + }); const [loading, setLoading] = useState(false); const [error, setError] = useState(); @@ -109,6 +129,8 @@ export default function HookForm({ hook_id: hook.id, title, instructions, + workspace_id: place.workspace_id, + session: place.session, ...signing, ...(watching ? { watch_filter } : {}), }, @@ -117,7 +139,8 @@ export default function HookForm({ path: "hooks/create-hook", body: { bot_id, - workspace_id: workspace_id || null, + workspace_id: place.workspace_id, + session: place.session, title, instructions, source, @@ -168,6 +191,21 @@ export default function HookForm({ info="What the bot does with each call. What the caller sends is added below this as data, never as instructions." onChange={(value) => setInstructions(value)} /> + {hook ? null : ( ({ + value: String(it.workspace_id ?? "own"), + title: it.name, + default: it.workspace_id == workspace_id, + }))} + changeHandler={(value) => onChange({ workspace_id: value == "own" ? null : Number(value), session: "new" })} + /> + ) : null} + {sessions ? ( +