feat: add gateway webhooks
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
- First-class Clawdis tools (browser, canvas, nodes, cron) replace the old `clawdis-*` skills; tool schemas are now injected directly into the agent runtime.
|
||||
- Custom model providers: `models.providers` merges into `~/.clawdis/agent/models.json` (merge/replace modes) for LiteLLM, local OpenAI-compatible servers, Anthropic proxies, etc.
|
||||
- Group chat activation modes: per-group `/activation mention|always` command with status visibility.
|
||||
- Gateway webhooks: external `wake` and isolated `agent` hooks with dedicated token auth.
|
||||
|
||||
### Breaking
|
||||
- Config refactor: `inbound.*` removed; use top-level `routing` (allowlists + group rules + transcription), `messages` (prefixes/timestamps), and `session` (scoping/store/mainKey). No legacy keys read.
|
||||
|
||||
@@ -311,6 +311,35 @@ Auth and Tailscale:
|
||||
- `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth.
|
||||
- `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown.
|
||||
|
||||
### `hooks` (Gateway webhooks)
|
||||
|
||||
Enable a simple HTTP webhook surface on the Gateway HTTP server.
|
||||
|
||||
Defaults:
|
||||
- enabled: `false`
|
||||
- path: `/hooks`
|
||||
|
||||
```json5
|
||||
{
|
||||
hooks: {
|
||||
enabled: true,
|
||||
token: "shared-secret",
|
||||
path: "/hooks"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Requests must include the hook token:
|
||||
- `Authorization: Bearer <token>` **or**
|
||||
- `x-clawdis-token: <token>` **or**
|
||||
- `?token=<token>`
|
||||
|
||||
Endpoints:
|
||||
- `POST /hooks/wake` → `{ text, mode?: "now"|"next-heartbeat" }`
|
||||
- `POST /hooks/agent` → `{ message, name?, sessionKey?, wakeMode?, deliver?, channel?, to?, thinking?, timeoutSeconds? }`
|
||||
|
||||
`/hooks/agent` always posts a summary into the main session (and can optionally trigger an immediate heartbeat via `wakeMode: "now"`).
|
||||
|
||||
### `canvasHost` (LAN/tailnet Canvas file server + live reload)
|
||||
|
||||
The Gateway serves a directory of HTML/CSS/JS over HTTP so iOS/Android nodes can simply `canvas.navigate` to it.
|
||||
|
||||
@@ -21,6 +21,11 @@ The UI talks directly to the Gateway WS and supports:
|
||||
- Config (`config.get`, `config.set`) for `~/.clawdis/clawdis.json`
|
||||
- Debug (status/health/models snapshots + manual calls)
|
||||
|
||||
## Webhooks
|
||||
|
||||
When `hooks.enabled=true`, the Gateway also exposes a small webhook surface on the same HTTP server.
|
||||
See `docs/configuration.md` → `hooks` for auth + payloads.
|
||||
|
||||
## Config (default-on)
|
||||
|
||||
The Control UI is **enabled by default** when assets are present (`dist/control-ui`).
|
||||
|
||||
111
docs/webhook.md
Normal file
111
docs/webhook.md
Normal file
@@ -0,0 +1,111 @@
|
||||
---
|
||||
summary: "Webhook ingress for wake and isolated agent runs"
|
||||
read_when:
|
||||
- Adding or changing webhook endpoints
|
||||
- Wiring external systems into Clawdis
|
||||
---
|
||||
|
||||
# Webhooks
|
||||
|
||||
Gateway can expose a small HTTP webhook surface for external triggers.
|
||||
|
||||
## Enable
|
||||
|
||||
```json5
|
||||
{
|
||||
hooks: {
|
||||
enabled: true,
|
||||
token: "shared-secret",
|
||||
path: "/hooks"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `hooks.token` is required when `hooks.enabled=true`.
|
||||
- `hooks.path` defaults to `/hooks`.
|
||||
|
||||
## Auth
|
||||
|
||||
Every request must include the hook token:
|
||||
- `Authorization: Bearer <token>`
|
||||
- or `x-clawdis-token: <token>`
|
||||
- or `?token=<token>`
|
||||
|
||||
## Endpoints
|
||||
|
||||
### `POST /hooks/wake`
|
||||
|
||||
Payload:
|
||||
```json
|
||||
{ "text": "System line", "mode": "now" }
|
||||
```
|
||||
|
||||
- `text` required (string)
|
||||
- `mode` optional: `now` | `next-heartbeat` (default `now`)
|
||||
|
||||
Effect:
|
||||
- Enqueues a system event for the **main** session
|
||||
- If `mode=now`, triggers an immediate heartbeat
|
||||
|
||||
### `POST /hooks/agent`
|
||||
|
||||
Payload:
|
||||
```json
|
||||
{
|
||||
"message": "Run this",
|
||||
"name": "Email",
|
||||
"sessionKey": "hook:email:msg-123",
|
||||
"wakeMode": "now",
|
||||
"deliver": false,
|
||||
"channel": "last",
|
||||
"to": "+15551234567",
|
||||
"thinking": "low",
|
||||
"timeoutSeconds": 120
|
||||
}
|
||||
```
|
||||
|
||||
- `message` required (string)
|
||||
- `name` optional (used in the summary prefix)
|
||||
- `sessionKey` optional (default random `hook:<uuid>`)
|
||||
- `wakeMode` optional: `now` | `next-heartbeat` (default `now`)
|
||||
- `deliver` optional (default `false`)
|
||||
- `channel` optional: `last` | `whatsapp` | `telegram`
|
||||
- `to` optional (channel-specific target)
|
||||
- `thinking` optional (override)
|
||||
- `timeoutSeconds` optional
|
||||
|
||||
Effect:
|
||||
- Runs an **isolated** agent turn (own session key)
|
||||
- Always posts a summary into the **main** session
|
||||
- If `wakeMode=now`, triggers an immediate heartbeat
|
||||
|
||||
## Responses
|
||||
|
||||
- `200` for `/hooks/wake`
|
||||
- `202` for `/hooks/agent` (async run started)
|
||||
- `401` on auth failure
|
||||
- `400` on invalid payload
|
||||
- `413` on oversized payloads
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:18789/hooks/wake \
|
||||
-H 'Authorization: Bearer SECRET' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"text":"New email received","mode":"now"}'
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:18789/hooks/agent \
|
||||
-H 'x-clawdis-token: SECRET' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"message":"Summarize inbox","name":"Email","wakeMode":"next-heartbeat"}'
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
- Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy.
|
||||
- Use a dedicated hook token; do not reuse gateway auth tokens.
|
||||
- Avoid including sensitive raw payloads in webhook logs.
|
||||
@@ -507,6 +507,30 @@ export function registerGatewayCli(program: Command) {
|
||||
}),
|
||||
);
|
||||
|
||||
gatewayCallOpts(
|
||||
gateway
|
||||
.command("wake")
|
||||
.description("Enqueue a system event and optionally trigger a heartbeat")
|
||||
.requiredOption("--text <text>", "System event text")
|
||||
.option(
|
||||
"--mode <mode>",
|
||||
"Wake mode (now|next-heartbeat)",
|
||||
"next-heartbeat",
|
||||
)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const result = await callGatewayCli("wake", opts, {
|
||||
mode: opts.mode,
|
||||
text: opts.text,
|
||||
});
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
gatewayCallOpts(
|
||||
gateway
|
||||
.command("send")
|
||||
|
||||
@@ -62,6 +62,12 @@ export type CronConfig = {
|
||||
maxConcurrentRuns?: number;
|
||||
};
|
||||
|
||||
export type HooksConfig = {
|
||||
enabled?: boolean;
|
||||
path?: string;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
export type TelegramConfig = {
|
||||
botToken?: string;
|
||||
requireMention?: boolean;
|
||||
@@ -271,6 +277,7 @@ export type ClawdisConfig = {
|
||||
web?: WebConfig;
|
||||
telegram?: TelegramConfig;
|
||||
cron?: CronConfig;
|
||||
hooks?: HooksConfig;
|
||||
bridge?: BridgeConfig;
|
||||
discovery?: DiscoveryConfig;
|
||||
canvasHost?: CanvasHostConfig;
|
||||
@@ -461,6 +468,13 @@ const ClawdisSchema = z.object({
|
||||
maxConcurrentRuns: z.number().int().positive().optional(),
|
||||
})
|
||||
.optional(),
|
||||
hooks: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
path: z.string().optional(),
|
||||
token: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
web: z
|
||||
.object({
|
||||
heartbeatSeconds: z.number().int().positive().optional(),
|
||||
|
||||
@@ -10,6 +10,7 @@ import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
|
||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||
import { GatewayLockError } from "../infra/gateway-lock.js";
|
||||
import { emitHeartbeatEvent } from "../infra/heartbeat-events.js";
|
||||
import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js";
|
||||
import { rawDataToString } from "../infra/ws.js";
|
||||
import { PROTOCOL_VERSION } from "./protocol/index.js";
|
||||
import {
|
||||
@@ -74,6 +75,9 @@ const piSdkMock = vi.hoisted(() => ({
|
||||
contextWindow?: number;
|
||||
}>,
|
||||
}));
|
||||
const cronIsolatedRun = vi.hoisted(() =>
|
||||
vi.fn(async () => ({ status: "ok", summary: "ok" })),
|
||||
);
|
||||
|
||||
vi.mock("@mariozechner/pi-coding-agent", async () => {
|
||||
const actual = await vi.importActual<
|
||||
@@ -101,6 +105,9 @@ vi.mock("../infra/bridge/server.js", () => ({
|
||||
};
|
||||
}),
|
||||
}));
|
||||
vi.mock("../cron/isolated-agent.js", () => ({
|
||||
runCronIsolatedAgentTurn: (...args: unknown[]) => cronIsolatedRun(...args),
|
||||
}));
|
||||
vi.mock("../infra/tailnet.js", () => ({
|
||||
pickPrimaryTailnetIPv4: () => testTailnetIPv4.value,
|
||||
pickPrimaryTailnetIPv6: () => undefined,
|
||||
@@ -112,6 +119,7 @@ let testCronStorePath: string | undefined;
|
||||
let testCronEnabled: boolean | undefined = false;
|
||||
let testGatewayBind: "auto" | "lan" | "tailnet" | "loopback" | undefined;
|
||||
let testGatewayAuth: Record<string, unknown> | undefined;
|
||||
let testHooksConfig: Record<string, unknown> | undefined;
|
||||
const sessionStoreSaveDelayMs = vi.hoisted(() => ({ value: 0 }));
|
||||
vi.mock("../config/sessions.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/sessions.js")>(
|
||||
@@ -197,6 +205,7 @@ vi.mock("../config/config.js", () => {
|
||||
if (testGatewayAuth) gateway.auth = testGatewayAuth;
|
||||
return Object.keys(gateway).length > 0 ? gateway : undefined;
|
||||
})(),
|
||||
hooks: testHooksConfig,
|
||||
cron: (() => {
|
||||
const cron: Record<string, unknown> = {};
|
||||
if (typeof testCronEnabled === "boolean")
|
||||
@@ -251,6 +260,9 @@ beforeEach(async () => {
|
||||
testTailnetIPv4.value = undefined;
|
||||
testGatewayBind = undefined;
|
||||
testGatewayAuth = undefined;
|
||||
testHooksConfig = undefined;
|
||||
cronIsolatedRun.mockClear();
|
||||
drainSystemEvents();
|
||||
__resetModelCatalogCacheForTest();
|
||||
piSdkMock.enabled = false;
|
||||
piSdkMock.discoverCalls = 0;
|
||||
@@ -413,6 +425,16 @@ async function rpcReq<T = unknown>(
|
||||
}>(ws, (o) => o.type === "res" && o.id === id);
|
||||
}
|
||||
|
||||
async function waitForSystemEvent(timeoutMs = 2000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const events = peekSystemEvents();
|
||||
if (events.length > 0) return events;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error("timeout waiting for system event");
|
||||
}
|
||||
|
||||
describe("gateway server", () => {
|
||||
test("voicewake.get returns defaults and voicewake.set broadcasts", async () => {
|
||||
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-"));
|
||||
@@ -3598,4 +3620,95 @@ describe("gateway server", () => {
|
||||
probe.close((err) => (err ? reject(err) : resolve())),
|
||||
);
|
||||
});
|
||||
|
||||
test("hooks wake requires auth", async () => {
|
||||
testHooksConfig = { enabled: true, token: "hook-secret" };
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const res = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text: "Ping" }),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("hooks wake enqueues system event", async () => {
|
||||
testHooksConfig = { enabled: true, token: "hook-secret" };
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const res = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer hook-secret",
|
||||
},
|
||||
body: JSON.stringify({ text: "Ping", mode: "next-heartbeat" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const events = await waitForSystemEvent();
|
||||
expect(events.some((e) => e.includes("Ping"))).toBe(true);
|
||||
drainSystemEvents();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("hooks agent posts summary to main", async () => {
|
||||
testHooksConfig = { enabled: true, token: "hook-secret" };
|
||||
cronIsolatedRun.mockResolvedValueOnce({
|
||||
status: "ok",
|
||||
summary: "done",
|
||||
});
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const res = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer hook-secret",
|
||||
},
|
||||
body: JSON.stringify({ message: "Do it", name: "Email" }),
|
||||
});
|
||||
expect(res.status).toBe(202);
|
||||
const events = await waitForSystemEvent();
|
||||
expect(events.some((e) => e.includes("Hook Email: done"))).toBe(true);
|
||||
drainSystemEvents();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("hooks wake accepts query token", async () => {
|
||||
testHooksConfig = { enabled: true, token: "hook-secret" };
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const res = await fetch(
|
||||
`http://127.0.0.1:${port}/hooks/wake?token=hook-secret`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text: "Query auth" }),
|
||||
},
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const events = await waitForSystemEvent();
|
||||
expect(events.some((e) => e.includes("Query auth"))).toBe(true);
|
||||
drainSystemEvents();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("hooks agent rejects invalid channel", async () => {
|
||||
testHooksConfig = { enabled: true, token: "hook-secret" };
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const res = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer hook-secret",
|
||||
},
|
||||
body: JSON.stringify({ message: "Nope", channel: "sms" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(peekSystemEvents().length).toBe(0);
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,7 +65,7 @@ import {
|
||||
} from "../cron/run-log.js";
|
||||
import { CronService } from "../cron/service.js";
|
||||
import { resolveCronStorePath } from "../cron/store.js";
|
||||
import type { CronJobCreate, CronJobPatch } from "../cron/types.js";
|
||||
import type { CronJob, CronJobCreate, CronJobPatch } from "../cron/types.js";
|
||||
import { isVerbose } from "../globals.js";
|
||||
import { onAgentEvent } from "../infra/agent-events.js";
|
||||
import { startGatewayBonjourAdvertiser } from "../infra/bonjour.js";
|
||||
@@ -146,6 +146,109 @@ import { handleControlUiHttpRequest } from "./control-ui.js";
|
||||
|
||||
ensureClawdisCliOnPath();
|
||||
|
||||
const DEFAULT_HOOKS_PATH = "/hooks";
|
||||
const DEFAULT_HOOKS_MAX_BODY_BYTES = 256 * 1024;
|
||||
|
||||
type HooksConfigResolved = {
|
||||
basePath: string;
|
||||
token: string;
|
||||
maxBodyBytes: number;
|
||||
};
|
||||
|
||||
function resolveHooksConfig(cfg: ClawdisConfig): HooksConfigResolved | null {
|
||||
if (cfg.hooks?.enabled !== true) return null;
|
||||
const token = cfg.hooks?.token?.trim();
|
||||
if (!token) {
|
||||
throw new Error("hooks.enabled requires hooks.token");
|
||||
}
|
||||
const rawPath = cfg.hooks?.path?.trim() || DEFAULT_HOOKS_PATH;
|
||||
const withSlash = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
|
||||
const trimmed =
|
||||
withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash;
|
||||
if (trimmed === "/") {
|
||||
throw new Error("hooks.path may not be '/'");
|
||||
}
|
||||
return {
|
||||
basePath: trimmed,
|
||||
token,
|
||||
maxBodyBytes: DEFAULT_HOOKS_MAX_BODY_BYTES,
|
||||
};
|
||||
}
|
||||
|
||||
function extractHookToken(
|
||||
req: IncomingMessage,
|
||||
url: URL,
|
||||
): string | undefined {
|
||||
const auth =
|
||||
typeof req.headers.authorization === "string"
|
||||
? req.headers.authorization.trim()
|
||||
: "";
|
||||
if (auth.toLowerCase().startsWith("bearer ")) {
|
||||
const token = auth.slice(7).trim();
|
||||
if (token) return token;
|
||||
}
|
||||
const headerToken =
|
||||
typeof req.headers["x-clawdis-token"] === "string"
|
||||
? req.headers["x-clawdis-token"].trim()
|
||||
: "";
|
||||
if (headerToken) return headerToken;
|
||||
const queryToken = url.searchParams.get("token");
|
||||
if (queryToken) return queryToken.trim();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function readJsonBody(
|
||||
req: IncomingMessage,
|
||||
maxBytes: number,
|
||||
): Promise<{ ok: true; value: unknown } | { ok: false; error: string }> {
|
||||
return await new Promise((resolve) => {
|
||||
let done = false;
|
||||
let total = 0;
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
if (done) return;
|
||||
total += chunk.length;
|
||||
if (total > maxBytes) {
|
||||
done = true;
|
||||
resolve({ ok: false, error: "payload too large" });
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on("end", () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
const raw = Buffer.concat(chunks).toString("utf-8").trim();
|
||||
if (!raw) {
|
||||
resolve({ ok: true, value: {} });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
resolve({ ok: true, value: parsed });
|
||||
} catch (err) {
|
||||
resolve({ ok: false, error: String(err) });
|
||||
}
|
||||
});
|
||||
req.on("error", (err) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
resolve({ ok: false, error: String(err) });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(
|
||||
res: import("node:http").ServerResponse,
|
||||
status: number,
|
||||
body: unknown,
|
||||
) {
|
||||
res.statusCode = status;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
const log = createSubsystemLogger("gateway");
|
||||
const logCanvas = log.child("canvas");
|
||||
const logBridge = log.child("bridge");
|
||||
@@ -155,6 +258,7 @@ const logProviders = log.child("providers");
|
||||
const logBrowser = log.child("browser");
|
||||
const logHealth = log.child("health");
|
||||
const logCron = log.child("cron");
|
||||
const logHooks = log.child("hooks");
|
||||
const logWsControl = log.child("ws");
|
||||
const logWhatsApp = logProviders.child("whatsapp");
|
||||
const logTelegram = logProviders.child("telegram");
|
||||
@@ -1174,6 +1278,7 @@ export async function startGatewayServer(
|
||||
password,
|
||||
allowTailscale,
|
||||
};
|
||||
const hooksConfig = resolveHooksConfig(cfgAtStart);
|
||||
const canvasHostEnabled =
|
||||
process.env.CLAWDIS_SKIP_CANVAS_HOST !== "1" &&
|
||||
cfgAtStart.canvasHost?.enabled !== false;
|
||||
@@ -1194,6 +1299,7 @@ export async function startGatewayServer(
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
let canvasHost: CanvasHostHandler | null = null;
|
||||
let canvasHostServer: CanvasHostServer | null = null;
|
||||
if (canvasHostEnabled) {
|
||||
@@ -1215,11 +1321,200 @@ export async function startGatewayServer(
|
||||
}
|
||||
}
|
||||
|
||||
const handleHooksRequest = async (
|
||||
req: IncomingMessage,
|
||||
res: import("node:http").ServerResponse,
|
||||
): Promise<boolean> => {
|
||||
if (!hooksConfig) return false;
|
||||
const url = new URL(req.url ?? "/", `http://${bindHost}:${port}`);
|
||||
const basePath = hooksConfig.basePath;
|
||||
if (
|
||||
url.pathname !== basePath &&
|
||||
!url.pathname.startsWith(`${basePath}/`)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const token = extractHookToken(req, url);
|
||||
if (!token || token !== hooksConfig.token) {
|
||||
res.statusCode = 401;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("Unauthorized");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method !== "POST") {
|
||||
res.statusCode = 405;
|
||||
res.setHeader("Allow", "POST");
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("Method Not Allowed");
|
||||
return true;
|
||||
}
|
||||
|
||||
const subPath = url.pathname
|
||||
.slice(basePath.length)
|
||||
.replace(/^\/+/, "");
|
||||
if (!subPath) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("Not Found");
|
||||
return true;
|
||||
}
|
||||
|
||||
const body = await readJsonBody(req, hooksConfig.maxBodyBytes);
|
||||
if (!body.ok) {
|
||||
const status = body.error === "payload too large" ? 413 : 400;
|
||||
sendJson(res, status, { ok: false, error: body.error });
|
||||
return true;
|
||||
}
|
||||
|
||||
const payload =
|
||||
typeof body.value === "object" && body.value !== null ? body.value : {};
|
||||
|
||||
if (subPath === "wake") {
|
||||
const text =
|
||||
typeof (payload as { text?: unknown }).text === "string"
|
||||
? (payload as { text?: string }).text.trim()
|
||||
: "";
|
||||
if (!text) {
|
||||
sendJson(res, 400, { ok: false, error: "text required" });
|
||||
return true;
|
||||
}
|
||||
const modeRaw = (payload as { mode?: unknown }).mode;
|
||||
const mode =
|
||||
modeRaw === "next-heartbeat" ? "next-heartbeat" : "now";
|
||||
enqueueSystemEvent(text);
|
||||
if (mode === "now") {
|
||||
requestReplyHeartbeatNow({ reason: "hook:wake" });
|
||||
}
|
||||
sendJson(res, 200, { ok: true, mode });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (subPath === "agent") {
|
||||
const message =
|
||||
typeof (payload as { message?: unknown }).message === "string"
|
||||
? (payload as { message?: string }).message.trim()
|
||||
: "";
|
||||
if (!message) {
|
||||
sendJson(res, 400, { ok: false, error: "message required" });
|
||||
return true;
|
||||
}
|
||||
|
||||
const nameRaw = (payload as { name?: unknown }).name;
|
||||
const name =
|
||||
typeof nameRaw === "string" && nameRaw.trim() ? nameRaw.trim() : "Hook";
|
||||
const wakeModeRaw = (payload as { wakeMode?: unknown }).wakeMode;
|
||||
const wakeMode =
|
||||
wakeModeRaw === "next-heartbeat" ? "next-heartbeat" : "now";
|
||||
const sessionKeyRaw = (payload as { sessionKey?: unknown }).sessionKey;
|
||||
const sessionKey =
|
||||
typeof sessionKeyRaw === "string" && sessionKeyRaw.trim()
|
||||
? sessionKeyRaw.trim()
|
||||
: `hook:${randomUUID()}`;
|
||||
|
||||
const channelRaw = (payload as { channel?: unknown }).channel;
|
||||
const channel =
|
||||
channelRaw === "whatsapp" ||
|
||||
channelRaw === "telegram" ||
|
||||
channelRaw === "last"
|
||||
? channelRaw
|
||||
: channelRaw === undefined
|
||||
? undefined
|
||||
: null;
|
||||
if (channel === null) {
|
||||
sendJson(res, 400, {
|
||||
ok: false,
|
||||
error: "channel must be last|whatsapp|telegram",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const toRaw = (payload as { to?: unknown }).to;
|
||||
const to =
|
||||
typeof toRaw === "string" && toRaw.trim() ? toRaw.trim() : undefined;
|
||||
const deliver = (payload as { deliver?: unknown }).deliver === true;
|
||||
const thinkingRaw = (payload as { thinking?: unknown }).thinking;
|
||||
const thinking =
|
||||
typeof thinkingRaw === "string" && thinkingRaw.trim()
|
||||
? thinkingRaw.trim()
|
||||
: undefined;
|
||||
const timeoutRaw = (payload as { timeoutSeconds?: unknown }).timeoutSeconds;
|
||||
const timeoutSeconds =
|
||||
typeof timeoutRaw === "number" && Number.isFinite(timeoutRaw) && timeoutRaw > 0
|
||||
? Math.floor(timeoutRaw)
|
||||
: undefined;
|
||||
|
||||
const jobId = randomUUID();
|
||||
const now = Date.now();
|
||||
const job: CronJob = {
|
||||
id: jobId,
|
||||
name,
|
||||
enabled: true,
|
||||
createdAtMs: now,
|
||||
updatedAtMs: now,
|
||||
schedule: { kind: "at", atMs: now },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode,
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message,
|
||||
thinking,
|
||||
timeoutSeconds,
|
||||
deliver,
|
||||
channel: channel ?? "last",
|
||||
to,
|
||||
},
|
||||
state: { nextRunAtMs: now },
|
||||
};
|
||||
|
||||
const runId = randomUUID();
|
||||
sendJson(res, 202, { ok: true, runId });
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const cfg = loadConfig();
|
||||
const result = await runCronIsolatedAgentTurn({
|
||||
cfg,
|
||||
deps,
|
||||
job,
|
||||
message,
|
||||
sessionKey,
|
||||
lane: "cron",
|
||||
});
|
||||
const summary =
|
||||
result.summary?.trim() ||
|
||||
result.error?.trim() ||
|
||||
result.status;
|
||||
const prefix =
|
||||
result.status === "ok" ? `Hook ${name}` : `Hook ${name} (${result.status})`;
|
||||
enqueueSystemEvent(`${prefix}: ${summary}`.trim());
|
||||
if (wakeMode === "now") {
|
||||
requestReplyHeartbeatNow({ reason: `hook:${jobId}` });
|
||||
}
|
||||
} catch (err) {
|
||||
logHooks.warn({ err: String(err) }, "hook agent failed");
|
||||
enqueueSystemEvent(`Hook ${name} (error): ${String(err)}`);
|
||||
if (wakeMode === "now") {
|
||||
requestReplyHeartbeatNow({ reason: `hook:${jobId}:error` });
|
||||
}
|
||||
}
|
||||
})();
|
||||
return true;
|
||||
}
|
||||
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("Not Found");
|
||||
return true;
|
||||
};
|
||||
|
||||
const httpServer: HttpServer = createHttpServer((req, res) => {
|
||||
// Don't interfere with WebSocket upgrades; ws handles the 'upgrade' event.
|
||||
if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") return;
|
||||
|
||||
void (async () => {
|
||||
if (await handleHooksRequest(req, res)) return;
|
||||
if (canvasHost) {
|
||||
if (await handleA2uiHttpRequest(req, res)) return;
|
||||
if (await canvasHost.handleHttpRequest(req, res)) return;
|
||||
|
||||
Reference in New Issue
Block a user