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 ? ( +