diff --git a/CHANGELOG.md b/CHANGELOG.md index 05fca3e40..3ebc9fd1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/configuration.md b/docs/configuration.md index 089587902..d30873cdb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 ` **or** +- `x-clawdis-token: ` **or** +- `?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. diff --git a/docs/web.md b/docs/web.md index 1cd7bbdf9..60fea4f58 100644 --- a/docs/web.md +++ b/docs/web.md @@ -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`). diff --git a/docs/webhook.md b/docs/webhook.md new file mode 100644 index 000000000..2194e2f88 --- /dev/null +++ b/docs/webhook.md @@ -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 ` +- or `x-clawdis-token: ` +- or `?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:`) +- `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. diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts index 9909d0587..a052aca14 100644 --- a/src/cli/gateway-cli.ts +++ b/src/cli/gateway-cli.ts @@ -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 ", "System event text") + .option( + "--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") diff --git a/src/config/config.ts b/src/config/config.ts index 166289ab4..b57ad10af 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -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(), diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index 7876bf83d..e8f3582f5 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -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 | undefined; +let testHooksConfig: Record | undefined; const sessionStoreSaveDelayMs = vi.hoisted(() => ({ value: 0 })); vi.mock("../config/sessions.js", async () => { const actual = await vi.importActual( @@ -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 = {}; 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( }>(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(); + }); }); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 18010fbdf..09fbe868c 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -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 => { + 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;