/** * /dsh-hooks/* HTTP routes for the web profile: status (incl. the hook * list and live runner stats), execution history, a dry-run-style test * trigger, notify-channel quick tests, the hook-list editor (writes back * to the profile's cordis.patch.yml with a backup), and the Feishu connect * flow (QR setup / cancel / config / test card / disconnect). Registered * only when the shared webserver service exists (web profile) — CLI/headless * environments never see them. Loopback-only by default, with JSON envelopes; POSTs * require an explicit application/json content-type (CSRF hardening, same * posture as dsh-aionui-panel). */ import type { IncomingMessage, ServerResponse } from 'node:http'; import type { HookSpec } from './config.js'; import type { HistorySink } from './history.js'; import { type HookRunner } from './runner.js'; import { type FeishuSetupManager } from './feishu-session.js'; import { runFeishuTest } from './feishu.js'; /** Minimal structural shape of the shared web server (dsh-host-webserver). */ export interface WebServerLike { register(spec: { kind: 'prefix' | 'exact'; path: string; handler: (req: IncomingMessage, res: ServerResponse) => void | Promise; }): () => void; } /** Plugin version, read from package.json (this package ships its own). */ export declare function pluginVersion(): string; /** DSH_HOOKS_ALLOWED_IPS: unset/empty = loopback; * = any; otherwise comma-separated IPs. */ export declare function isLoopbackRequest(req: IncomingMessage): boolean; /** * Host/Origin fence against DNS rebinding. * * The peer-IP check cannot see rebinding: a page on `evil.example` that the * browser resolves to 127.0.0.1 reaches us from loopback while the browser * treats it as same-origin — no preflight, and the response is readable. Both * `Host` and `Origin` still carry the attacker's hostname, so validating them * closes the hole. Set `DSH_HOOKS_ALLOWED_HOSTS` when a tunnel/proxy is in * front of the GUI (its forwarded hostname is not loopback). */ export declare function isTrustedHostRequest(req: IncomingMessage): boolean; /** Body size cap for one POST (bytes); larger requests answer 413. */ export declare const MAX_BODY_BYTES: number; /** * Whether a request declares a JSON body. The media type is compared exactly * (parameters such as `; charset=utf-8` ignored): a prefix test would accept * `application/jsonp`, which is what the CSRF-hardening rule is meant to * exclude. Also collapses the eight identical checks the POST routes used to * carry inline. */ export declare function isJsonContentType(req: IncomingMessage): boolean; /** Outcome of reading one JSON request body. */ export interface JsonBodyRead { /** Parsed value; absent when the body was empty, too large, or not JSON. */ value?: unknown; /** The body exceeded {@link MAX_BODY_BYTES} (answer 413, not "malformed"). */ tooLarge?: boolean; } export interface FeishuRouteDeps { /** QR-scan session manager (one in-flight flow at a time). */ manager: FeishuSetupManager; /** Test-card sender, injectable for tests. */ runTest?: typeof runFeishuTest; /** Credential file the status route summarizes. */ configPath?: string; } export interface HookRoutesOptions { hooks: readonly HookSpec[]; history: HistorySink; version?: string; /** * Profile this plugin is running inside — surfaced in `/dsh-hooks/status` so * the settings panel can point the editor at the profile whose hooks are * actually live instead of the hard-coded `web` default. Defaults to * `DSH_PROFILE` (set by the host), then to an empty string when unknown. */ profile?: string; feishu?: FeishuRouteDeps; /** Live runner counters for the diagnostics badge. */ runner?: Pick; /** Profile → patch-file resolver, injectable so tests never touch the real home. */ resolvePatchFile?: (profile: string) => string; } /** Sanitized per-hook description for the settings panel (regex sources, no RegExp objects). */ export declare function describeHooks(hooks: readonly HookSpec[]): { index: number; on: "agent/created" | "agent/disposed" | "agent/error" | "agent/status" | "approval/asked" | "approval/decided" | "hook/failed" | "session/created" | "session/disposed" | "session/title" | "step/end" | "tool/call" | "tool/result" | "tree/settled" | "turn/end" | "turn/start" | "usage/daily" | "user/message"; when: "aborted" | "blocked" | "completed" | "error" | "forked" | "interrupted" | "max-tokens" | undefined; match: { [k: string]: string; } | undefined; run: string | undefined; notify: { channel: "desktop" | "feishu" | "webhook"; url: string | undefined; slack: boolean | undefined; } | undefined; input: "env" | "stdin" | undefined; timeoutMs: number | undefined; retries: number | undefined; retryDelayMs: number | undefined; enabled: boolean | undefined; cwd: string | undefined; maxConcurrent: number | undefined; debounceMs: number | undefined; }[]; /** Create the /dsh-hooks route handler (exported for tests). */ export declare function createHookHandler(options: HookRoutesOptions): (req: IncomingMessage, res: ServerResponse) => Promise; /** Register the /dsh-hooks prefix route on the shared web server. */ export declare function registerHookRoutes(webServer: WebServerLike, options: HookRoutesOptions): () => void;