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.
|
- 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.
|
- 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.
|
- 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
|
### 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.
|
- 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.mode: "funnel"` exposes the dashboard publicly; requires auth.
|
||||||
- `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown.
|
- `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)
|
### `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.
|
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`
|
- Config (`config.get`, `config.set`) for `~/.clawdis/clawdis.json`
|
||||||
- Debug (status/health/models snapshots + manual calls)
|
- 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)
|
## Config (default-on)
|
||||||
|
|
||||||
The Control UI is **enabled by default** when assets are present (`dist/control-ui`).
|
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(
|
gatewayCallOpts(
|
||||||
gateway
|
gateway
|
||||||
.command("send")
|
.command("send")
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ export type CronConfig = {
|
|||||||
maxConcurrentRuns?: number;
|
maxConcurrentRuns?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type HooksConfig = {
|
||||||
|
enabled?: boolean;
|
||||||
|
path?: string;
|
||||||
|
token?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type TelegramConfig = {
|
export type TelegramConfig = {
|
||||||
botToken?: string;
|
botToken?: string;
|
||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
@@ -271,6 +277,7 @@ export type ClawdisConfig = {
|
|||||||
web?: WebConfig;
|
web?: WebConfig;
|
||||||
telegram?: TelegramConfig;
|
telegram?: TelegramConfig;
|
||||||
cron?: CronConfig;
|
cron?: CronConfig;
|
||||||
|
hooks?: HooksConfig;
|
||||||
bridge?: BridgeConfig;
|
bridge?: BridgeConfig;
|
||||||
discovery?: DiscoveryConfig;
|
discovery?: DiscoveryConfig;
|
||||||
canvasHost?: CanvasHostConfig;
|
canvasHost?: CanvasHostConfig;
|
||||||
@@ -461,6 +468,13 @@ const ClawdisSchema = z.object({
|
|||||||
maxConcurrentRuns: z.number().int().positive().optional(),
|
maxConcurrentRuns: z.number().int().positive().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
hooks: z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
path: z.string().optional(),
|
||||||
|
token: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
web: z
|
web: z
|
||||||
.object({
|
.object({
|
||||||
heartbeatSeconds: z.number().int().positive().optional(),
|
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 { emitAgentEvent } from "../infra/agent-events.js";
|
||||||
import { GatewayLockError } from "../infra/gateway-lock.js";
|
import { GatewayLockError } from "../infra/gateway-lock.js";
|
||||||
import { emitHeartbeatEvent } from "../infra/heartbeat-events.js";
|
import { emitHeartbeatEvent } from "../infra/heartbeat-events.js";
|
||||||
|
import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js";
|
||||||
import { rawDataToString } from "../infra/ws.js";
|
import { rawDataToString } from "../infra/ws.js";
|
||||||
import { PROTOCOL_VERSION } from "./protocol/index.js";
|
import { PROTOCOL_VERSION } from "./protocol/index.js";
|
||||||
import {
|
import {
|
||||||
@@ -74,6 +75,9 @@ const piSdkMock = vi.hoisted(() => ({
|
|||||||
contextWindow?: number;
|
contextWindow?: number;
|
||||||
}>,
|
}>,
|
||||||
}));
|
}));
|
||||||
|
const cronIsolatedRun = vi.hoisted(() =>
|
||||||
|
vi.fn(async () => ({ status: "ok", summary: "ok" })),
|
||||||
|
);
|
||||||
|
|
||||||
vi.mock("@mariozechner/pi-coding-agent", async () => {
|
vi.mock("@mariozechner/pi-coding-agent", async () => {
|
||||||
const actual = await vi.importActual<
|
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", () => ({
|
vi.mock("../infra/tailnet.js", () => ({
|
||||||
pickPrimaryTailnetIPv4: () => testTailnetIPv4.value,
|
pickPrimaryTailnetIPv4: () => testTailnetIPv4.value,
|
||||||
pickPrimaryTailnetIPv6: () => undefined,
|
pickPrimaryTailnetIPv6: () => undefined,
|
||||||
@@ -112,6 +119,7 @@ let testCronStorePath: string | undefined;
|
|||||||
let testCronEnabled: boolean | undefined = false;
|
let testCronEnabled: boolean | undefined = false;
|
||||||
let testGatewayBind: "auto" | "lan" | "tailnet" | "loopback" | undefined;
|
let testGatewayBind: "auto" | "lan" | "tailnet" | "loopback" | undefined;
|
||||||
let testGatewayAuth: Record<string, unknown> | undefined;
|
let testGatewayAuth: Record<string, unknown> | undefined;
|
||||||
|
let testHooksConfig: Record<string, unknown> | undefined;
|
||||||
const sessionStoreSaveDelayMs = vi.hoisted(() => ({ value: 0 }));
|
const sessionStoreSaveDelayMs = vi.hoisted(() => ({ value: 0 }));
|
||||||
vi.mock("../config/sessions.js", async () => {
|
vi.mock("../config/sessions.js", async () => {
|
||||||
const actual = await vi.importActual<typeof import("../config/sessions.js")>(
|
const actual = await vi.importActual<typeof import("../config/sessions.js")>(
|
||||||
@@ -197,6 +205,7 @@ vi.mock("../config/config.js", () => {
|
|||||||
if (testGatewayAuth) gateway.auth = testGatewayAuth;
|
if (testGatewayAuth) gateway.auth = testGatewayAuth;
|
||||||
return Object.keys(gateway).length > 0 ? gateway : undefined;
|
return Object.keys(gateway).length > 0 ? gateway : undefined;
|
||||||
})(),
|
})(),
|
||||||
|
hooks: testHooksConfig,
|
||||||
cron: (() => {
|
cron: (() => {
|
||||||
const cron: Record<string, unknown> = {};
|
const cron: Record<string, unknown> = {};
|
||||||
if (typeof testCronEnabled === "boolean")
|
if (typeof testCronEnabled === "boolean")
|
||||||
@@ -251,6 +260,9 @@ beforeEach(async () => {
|
|||||||
testTailnetIPv4.value = undefined;
|
testTailnetIPv4.value = undefined;
|
||||||
testGatewayBind = undefined;
|
testGatewayBind = undefined;
|
||||||
testGatewayAuth = undefined;
|
testGatewayAuth = undefined;
|
||||||
|
testHooksConfig = undefined;
|
||||||
|
cronIsolatedRun.mockClear();
|
||||||
|
drainSystemEvents();
|
||||||
__resetModelCatalogCacheForTest();
|
__resetModelCatalogCacheForTest();
|
||||||
piSdkMock.enabled = false;
|
piSdkMock.enabled = false;
|
||||||
piSdkMock.discoverCalls = 0;
|
piSdkMock.discoverCalls = 0;
|
||||||
@@ -413,6 +425,16 @@ async function rpcReq<T = unknown>(
|
|||||||
}>(ws, (o) => o.type === "res" && o.id === id);
|
}>(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", () => {
|
describe("gateway server", () => {
|
||||||
test("voicewake.get returns defaults and voicewake.set broadcasts", async () => {
|
test("voicewake.get returns defaults and voicewake.set broadcasts", async () => {
|
||||||
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-"));
|
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())),
|
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";
|
} from "../cron/run-log.js";
|
||||||
import { CronService } from "../cron/service.js";
|
import { CronService } from "../cron/service.js";
|
||||||
import { resolveCronStorePath } from "../cron/store.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 { isVerbose } from "../globals.js";
|
||||||
import { onAgentEvent } from "../infra/agent-events.js";
|
import { onAgentEvent } from "../infra/agent-events.js";
|
||||||
import { startGatewayBonjourAdvertiser } from "../infra/bonjour.js";
|
import { startGatewayBonjourAdvertiser } from "../infra/bonjour.js";
|
||||||
@@ -146,6 +146,109 @@ import { handleControlUiHttpRequest } from "./control-ui.js";
|
|||||||
|
|
||||||
ensureClawdisCliOnPath();
|
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 log = createSubsystemLogger("gateway");
|
||||||
const logCanvas = log.child("canvas");
|
const logCanvas = log.child("canvas");
|
||||||
const logBridge = log.child("bridge");
|
const logBridge = log.child("bridge");
|
||||||
@@ -155,6 +258,7 @@ const logProviders = log.child("providers");
|
|||||||
const logBrowser = log.child("browser");
|
const logBrowser = log.child("browser");
|
||||||
const logHealth = log.child("health");
|
const logHealth = log.child("health");
|
||||||
const logCron = log.child("cron");
|
const logCron = log.child("cron");
|
||||||
|
const logHooks = log.child("hooks");
|
||||||
const logWsControl = log.child("ws");
|
const logWsControl = log.child("ws");
|
||||||
const logWhatsApp = logProviders.child("whatsapp");
|
const logWhatsApp = logProviders.child("whatsapp");
|
||||||
const logTelegram = logProviders.child("telegram");
|
const logTelegram = logProviders.child("telegram");
|
||||||
@@ -1174,6 +1278,7 @@ export async function startGatewayServer(
|
|||||||
password,
|
password,
|
||||||
allowTailscale,
|
allowTailscale,
|
||||||
};
|
};
|
||||||
|
const hooksConfig = resolveHooksConfig(cfgAtStart);
|
||||||
const canvasHostEnabled =
|
const canvasHostEnabled =
|
||||||
process.env.CLAWDIS_SKIP_CANVAS_HOST !== "1" &&
|
process.env.CLAWDIS_SKIP_CANVAS_HOST !== "1" &&
|
||||||
cfgAtStart.canvasHost?.enabled !== false;
|
cfgAtStart.canvasHost?.enabled !== false;
|
||||||
@@ -1194,6 +1299,7 @@ export async function startGatewayServer(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
let canvasHost: CanvasHostHandler | null = null;
|
let canvasHost: CanvasHostHandler | null = null;
|
||||||
let canvasHostServer: CanvasHostServer | null = null;
|
let canvasHostServer: CanvasHostServer | null = null;
|
||||||
if (canvasHostEnabled) {
|
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) => {
|
const httpServer: HttpServer = createHttpServer((req, res) => {
|
||||||
// Don't interfere with WebSocket upgrades; ws handles the 'upgrade' event.
|
// Don't interfere with WebSocket upgrades; ws handles the 'upgrade' event.
|
||||||
if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") return;
|
if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") return;
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
if (await handleHooksRequest(req, res)) return;
|
||||||
if (canvasHost) {
|
if (canvasHost) {
|
||||||
if (await handleA2uiHttpRequest(req, res)) return;
|
if (await handleA2uiHttpRequest(req, res)) return;
|
||||||
if (await canvasHost.handleHttpRequest(req, res)) return;
|
if (await canvasHost.handleHttpRequest(req, res)) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user