mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
refactor(gateway): hard-break plugin wildcard http handlers
This commit is contained in:
@@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai
|
||||
- **BREAKING:** Zalo Personal plugin (`@openclaw/zalouser`) no longer depends on external `zca`-compatible CLI binaries (`openzca`, `zca-cli`) for runtime send/listen/login; operators should use `openclaw channels login --channel zalouser` after upgrade to refresh sessions in the new JS-native path.
|
||||
- **BREAKING:** Node exec approval payloads now require `systemRunPlan`. `host=node` approval requests without that plan are rejected.
|
||||
- **BREAKING:** Node `system.run` execution now pins path-token commands to the canonical executable path (`realpath`) in both allowlist and approval execution flows. Integrations/tests that asserted token-form argv (for example `tr`) must now accept canonical paths (for example `/usr/bin/tr`).
|
||||
- **BREAKING:** Plugin SDK removed `api.registerHttpHandler(...)`. Plugins must register explicit HTTP routes via `api.registerHttpRoute({ path, auth, match, handler })`, and dynamic webhook lifecycles should use `registerPluginHttpRoute(...)`.
|
||||
|
||||
### Fixes
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ If you’re looking for **how to use BlueBubbles as an agent/tool user**, see:
|
||||
|
||||
- Extension package: `extensions/bluebubbles/` (entry: `index.ts`).
|
||||
- Channel implementation: `extensions/bluebubbles/src/channel.ts`.
|
||||
- Webhook handling: `extensions/bluebubbles/src/monitor.ts` (register via `api.registerHttpHandler`).
|
||||
- Webhook handling: `extensions/bluebubbles/src/monitor.ts` (register per-account route via `registerPluginHttpRoute`).
|
||||
- REST helpers: `extensions/bluebubbles/src/send.ts` + `extensions/bluebubbles/src/probe.ts`.
|
||||
- Runtime bridge: `extensions/bluebubbles/src/runtime.ts` (set via `api.runtime`).
|
||||
- Catalog entry for onboarding: `src/channels/plugins/catalog.ts`.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import { bluebubblesPlugin } from "./src/channel.js";
|
||||
import { handleBlueBubblesWebhookRequest } from "./src/monitor.js";
|
||||
import { setBlueBubblesRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
@@ -12,7 +11,6 @@ const plugin = {
|
||||
register(api: OpenClawPluginApi) {
|
||||
setBlueBubblesRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: bluebubblesPlugin });
|
||||
api.registerHttpHandler(handleBlueBubblesWebhookRequest);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -530,10 +530,20 @@ export async function monitorBlueBubblesProvider(
|
||||
path,
|
||||
statusSink,
|
||||
});
|
||||
const unregisterRoute = registerPluginHttpRoute({
|
||||
path,
|
||||
auth: "plugin",
|
||||
match: "exact",
|
||||
pluginId: "bluebubbles",
|
||||
accountId: account.accountId,
|
||||
log: (message) => logVerbose(core, runtime, message),
|
||||
handler: handleBlueBubblesWebhookRequest,
|
||||
});
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const stop = () => {
|
||||
unregister();
|
||||
unregisterRoute();
|
||||
resolve();
|
||||
};
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ import { createMockServerResponse } from "../../src/test-utils/mock-http-respons
|
||||
import plugin from "./index.js";
|
||||
|
||||
describe("diffs plugin registration", () => {
|
||||
it("registers the tool, http handler, and prompt guidance hook", () => {
|
||||
it("registers the tool, http route, and prompt guidance hook", () => {
|
||||
const registerTool = vi.fn();
|
||||
const registerHttpHandler = vi.fn();
|
||||
const registerHttpRoute = vi.fn();
|
||||
const on = vi.fn();
|
||||
|
||||
plugin.register?.({
|
||||
@@ -23,8 +23,7 @@ describe("diffs plugin registration", () => {
|
||||
},
|
||||
registerTool,
|
||||
registerHook() {},
|
||||
registerHttpHandler,
|
||||
registerHttpRoute() {},
|
||||
registerHttpRoute,
|
||||
registerChannel() {},
|
||||
registerGatewayMethod() {},
|
||||
registerCli() {},
|
||||
@@ -38,7 +37,12 @@ describe("diffs plugin registration", () => {
|
||||
});
|
||||
|
||||
expect(registerTool).toHaveBeenCalledTimes(1);
|
||||
expect(registerHttpHandler).toHaveBeenCalledTimes(1);
|
||||
expect(registerHttpRoute).toHaveBeenCalledTimes(1);
|
||||
expect(registerHttpRoute.mock.calls[0]?.[0]).toMatchObject({
|
||||
path: "/plugins/diffs",
|
||||
auth: "plugin",
|
||||
match: "prefix",
|
||||
});
|
||||
expect(on).toHaveBeenCalledTimes(1);
|
||||
expect(on.mock.calls[0]?.[0]).toBe("before_prompt_build");
|
||||
});
|
||||
@@ -47,7 +51,7 @@ describe("diffs plugin registration", () => {
|
||||
let registeredTool:
|
||||
| { execute?: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown> }
|
||||
| undefined;
|
||||
let registeredHttpHandler:
|
||||
let registeredHttpRouteHandler:
|
||||
| ((
|
||||
req: IncomingMessage,
|
||||
res: ReturnType<typeof createMockServerResponse>,
|
||||
@@ -85,10 +89,9 @@ describe("diffs plugin registration", () => {
|
||||
registeredTool = typeof tool === "function" ? undefined : tool;
|
||||
},
|
||||
registerHook() {},
|
||||
registerHttpHandler(handler) {
|
||||
registeredHttpHandler = handler as typeof registeredHttpHandler;
|
||||
registerHttpRoute(params) {
|
||||
registeredHttpRouteHandler = params.handler as typeof registeredHttpRouteHandler;
|
||||
},
|
||||
registerHttpRoute() {},
|
||||
registerChannel() {},
|
||||
registerGatewayMethod() {},
|
||||
registerCli() {},
|
||||
@@ -109,7 +112,7 @@ describe("diffs plugin registration", () => {
|
||||
(result as { details?: Record<string, unknown> } | undefined)?.details?.viewerPath,
|
||||
);
|
||||
const res = createMockServerResponse();
|
||||
const handled = await registeredHttpHandler?.(
|
||||
const handled = await registeredHttpRouteHandler?.(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: viewerPath,
|
||||
|
||||
@@ -25,13 +25,16 @@ const plugin = {
|
||||
});
|
||||
|
||||
api.registerTool(createDiffsTool({ api, store, defaults }));
|
||||
api.registerHttpHandler(
|
||||
createDiffsHttpHandler({
|
||||
api.registerHttpRoute({
|
||||
path: "/plugins/diffs",
|
||||
auth: "plugin",
|
||||
match: "prefix",
|
||||
handler: createDiffsHttpHandler({
|
||||
store,
|
||||
logger: api.logger,
|
||||
allowRemoteViewer: security.allowRemoteViewer,
|
||||
}),
|
||||
);
|
||||
});
|
||||
api.on("before_prompt_build", async () => ({
|
||||
prependContext: DIFFS_AGENT_GUIDANCE,
|
||||
}));
|
||||
|
||||
@@ -434,7 +434,6 @@ function createApi(): OpenClawPluginApi {
|
||||
},
|
||||
registerTool() {},
|
||||
registerHook() {},
|
||||
registerHttpHandler() {},
|
||||
registerHttpRoute() {},
|
||||
registerChannel() {},
|
||||
registerGatewayMethod() {},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import { googlechatDock, googlechatPlugin } from "./src/channel.js";
|
||||
import { handleGoogleChatWebhookRequest } from "./src/monitor.js";
|
||||
import { setGoogleChatRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
@@ -12,7 +11,6 @@ const plugin = {
|
||||
register(api: OpenClawPluginApi) {
|
||||
setGoogleChatRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: googlechatPlugin, dock: googlechatDock });
|
||||
api.registerHttpHandler(handleGoogleChatWebhookRequest);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
readJsonBodyWithLimit,
|
||||
registerPluginHttpRoute,
|
||||
registerWebhookTarget,
|
||||
registerPluginHttpRoute,
|
||||
rejectNonPostWebhookRequest,
|
||||
isDangerousNameMatchingEnabled,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
@@ -969,7 +970,7 @@ export function monitorGoogleChatProvider(options: GoogleChatMonitorOptions): ()
|
||||
const audience = options.account.config.audience?.trim();
|
||||
const mediaMaxMb = options.account.config.mediaMaxMb ?? 20;
|
||||
|
||||
const unregister = registerGoogleChatWebhookTarget({
|
||||
const unregisterTarget = registerGoogleChatWebhookTarget({
|
||||
account: options.account,
|
||||
config: options.config,
|
||||
runtime: options.runtime,
|
||||
@@ -980,8 +981,20 @@ export function monitorGoogleChatProvider(options: GoogleChatMonitorOptions): ()
|
||||
statusSink: options.statusSink,
|
||||
mediaMaxMb,
|
||||
});
|
||||
const unregisterRoute = registerPluginHttpRoute({
|
||||
path: webhookPath,
|
||||
auth: "plugin",
|
||||
match: "exact",
|
||||
pluginId: "googlechat",
|
||||
accountId: options.account.accountId,
|
||||
log: (message) => logVerbose(core, options.runtime, message),
|
||||
handler: handleGoogleChatWebhookRequest,
|
||||
});
|
||||
|
||||
return unregister;
|
||||
return () => {
|
||||
unregisterTarget();
|
||||
unregisterRoute();
|
||||
};
|
||||
}
|
||||
|
||||
export async function startGoogleChatMonitor(
|
||||
|
||||
@@ -38,7 +38,6 @@ function fakeApi(overrides: Partial<OpenClawPluginApi> = {}): OpenClawPluginApi
|
||||
runtime: { version: "test" } as any,
|
||||
logger: { info() {}, warn() {}, error() {}, debug() {} },
|
||||
registerTool() {},
|
||||
registerHttpHandler() {},
|
||||
registerChannel() {},
|
||||
registerGatewayMethod() {},
|
||||
registerCli() {},
|
||||
|
||||
@@ -61,7 +61,12 @@ const plugin = {
|
||||
log: api.logger,
|
||||
});
|
||||
|
||||
api.registerHttpHandler(httpHandler);
|
||||
api.registerHttpRoute({
|
||||
path: "/api/channels/nostr",
|
||||
auth: "gateway",
|
||||
match: "prefix",
|
||||
handler: httpHandler,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import { zaloDock, zaloPlugin } from "./src/channel.js";
|
||||
import { handleZaloWebhookRequest } from "./src/monitor.js";
|
||||
import { setZaloRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
@@ -12,7 +11,6 @@ const plugin = {
|
||||
register(api: OpenClawPluginApi) {
|
||||
setZaloRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: zaloPlugin, dock: zaloDock });
|
||||
api.registerHttpHandler(handleZaloWebhookRequest);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -653,7 +653,17 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<
|
||||
mediaMaxMb: effectiveMediaMaxMb,
|
||||
fetcher,
|
||||
});
|
||||
const unregisterRoute = registerPluginHttpRoute({
|
||||
path,
|
||||
auth: "plugin",
|
||||
match: "exact",
|
||||
pluginId: "zalo",
|
||||
accountId: account.accountId,
|
||||
log: (message) => logVerbose(core, runtime, message),
|
||||
handler: handleZaloWebhookRequest,
|
||||
});
|
||||
stopHandlers.push(unregister);
|
||||
stopHandlers.push(unregisterRoute);
|
||||
abortSignal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json",
|
||||
"build:strict-smoke": "pnpm canvas:a2ui:bundle && tsdown && pnpm build:plugin-sdk:dts",
|
||||
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
|
||||
"check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope && pnpm check:host-env-policy:swift",
|
||||
"check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:plugins:no-register-http-handler && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope && pnpm check:host-env-policy:swift",
|
||||
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links",
|
||||
"check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check",
|
||||
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500",
|
||||
@@ -102,6 +102,7 @@
|
||||
"lint:docs": "pnpm dlx markdownlint-cli2",
|
||||
"lint:docs:fix": "pnpm dlx markdownlint-cli2 --fix",
|
||||
"lint:fix": "oxlint --type-aware --fix && pnpm format",
|
||||
"lint:plugins:no-register-http-handler": "node scripts/check-no-register-http-handler.mjs",
|
||||
"lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)",
|
||||
"lint:tmp:channel-agnostic-boundaries": "node scripts/check-channel-agnostic-boundaries.mjs",
|
||||
"lint:tmp:no-random-messaging": "node scripts/check-no-random-messaging-tmp.mjs",
|
||||
|
||||
@@ -70,7 +70,6 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
|
||||
channels,
|
||||
providers: [],
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
|
||||
@@ -48,13 +48,17 @@ import {
|
||||
import { sendGatewayAuthFailure, setDefaultSecurityHeaders } from "./http-common.js";
|
||||
import { handleOpenAiHttpRequest } from "./openai-http.js";
|
||||
import { handleOpenResponsesHttpRequest } from "./openresponses-http.js";
|
||||
import { hasSecurityPathCanonicalizationAnomaly } from "./security-path.js";
|
||||
import { isProtectedPluginRoutePath } from "./security-path.js";
|
||||
import {
|
||||
authorizeCanvasRequest,
|
||||
enforcePluginRouteGatewayAuth,
|
||||
isCanvasPath,
|
||||
} from "./server/http-auth.js";
|
||||
import {
|
||||
isProtectedPluginRoutePathFromContext,
|
||||
resolvePluginRoutePathContext,
|
||||
type PluginHttpRequestHandler,
|
||||
type PluginRoutePathContext,
|
||||
} from "./server/plugins-http.js";
|
||||
import type { GatewayWsClient } from "./server/ws-types.js";
|
||||
import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js";
|
||||
|
||||
@@ -81,8 +85,12 @@ const GATEWAY_PROBE_STATUS_BY_PATH = new Map<string, "live" | "ready">([
|
||||
["/readyz", "ready"],
|
||||
]);
|
||||
|
||||
function shouldEnforceDefaultPluginGatewayAuth(pathname: string): boolean {
|
||||
return hasSecurityPathCanonicalizationAnomaly(pathname) || isProtectedPluginRoutePath(pathname);
|
||||
function shouldEnforceDefaultPluginGatewayAuth(pathContext: PluginRoutePathContext): boolean {
|
||||
return (
|
||||
pathContext.malformedEncoding ||
|
||||
pathContext.decodePassLimitReached ||
|
||||
isProtectedPluginRoutePathFromContext(pathContext)
|
||||
);
|
||||
}
|
||||
|
||||
function handleGatewayProbeRequest(
|
||||
@@ -391,8 +399,8 @@ export function createGatewayHttpServer(opts: {
|
||||
openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig;
|
||||
strictTransportSecurityHeader?: string;
|
||||
handleHooksRequest: HooksRequestHandler;
|
||||
handlePluginRequest?: HooksRequestHandler;
|
||||
shouldEnforcePluginGatewayAuth?: (requestPath: string) => boolean;
|
||||
handlePluginRequest?: PluginHttpRequestHandler;
|
||||
shouldEnforcePluginGatewayAuth?: (pathContext: PluginRoutePathContext) => boolean;
|
||||
resolvedAuth: ResolvedGatewayAuth;
|
||||
/** Optional rate limiter for auth brute-force protection. */
|
||||
rateLimiter?: AuthRateLimiter;
|
||||
@@ -445,7 +453,9 @@ export function createGatewayHttpServer(opts: {
|
||||
req.url = scopedCanvas.rewrittenUrl;
|
||||
}
|
||||
const requestPath = new URL(req.url ?? "/", "http://localhost").pathname;
|
||||
|
||||
const pluginPathContext = handlePluginRequest
|
||||
? resolvePluginRoutePathContext(requestPath)
|
||||
: null;
|
||||
const requestStages: GatewayHttpRequestStage[] = [
|
||||
{
|
||||
name: "hooks",
|
||||
@@ -466,7 +476,6 @@ export function createGatewayHttpServer(opts: {
|
||||
run: () => handleSlackHttpRequest(req, res),
|
||||
},
|
||||
];
|
||||
|
||||
if (openResponsesEnabled) {
|
||||
requestStages.push({
|
||||
name: "openresponses",
|
||||
@@ -550,9 +559,10 @@ export function createGatewayHttpServer(opts: {
|
||||
requestStages.push({
|
||||
name: "plugin-auth",
|
||||
run: async () => {
|
||||
const pathContext = pluginPathContext ?? resolvePluginRoutePathContext(requestPath);
|
||||
if (
|
||||
!(shouldEnforcePluginGatewayAuth ?? shouldEnforceDefaultPluginGatewayAuth)(
|
||||
requestPath,
|
||||
pathContext,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
@@ -573,7 +583,10 @@ export function createGatewayHttpServer(opts: {
|
||||
});
|
||||
requestStages.push({
|
||||
name: "plugin-http",
|
||||
run: () => handlePluginRequest(req, res),
|
||||
run: () => {
|
||||
const pathContext = pluginPathContext ?? resolvePluginRoutePathContext(requestPath);
|
||||
return handlePluginRequest(req, res, pathContext);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({
|
||||
commands: [],
|
||||
providers: [],
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
|
||||
@@ -30,6 +30,7 @@ import { listenGatewayHttpServer } from "./server/http-listen.js";
|
||||
import {
|
||||
createGatewayPluginRequestHandler,
|
||||
shouldEnforceGatewayAuthForPluginPath,
|
||||
type PluginRoutePathContext,
|
||||
} from "./server/plugins-http.js";
|
||||
import type { GatewayTlsRuntime } from "./server/tls.js";
|
||||
import type { GatewayWsClient } from "./server/ws-types.js";
|
||||
@@ -118,8 +119,8 @@ export async function createGatewayRuntimeState(params: {
|
||||
registry: params.pluginRegistry,
|
||||
log: params.logPlugins,
|
||||
});
|
||||
const shouldEnforcePluginGatewayAuth = (requestPath: string): boolean => {
|
||||
return shouldEnforceGatewayAuthForPluginPath(params.pluginRegistry, requestPath);
|
||||
const shouldEnforcePluginGatewayAuth = (pathContext: PluginRoutePathContext): boolean => {
|
||||
return shouldEnforceGatewayAuthForPluginPath(params.pluginRegistry, pathContext);
|
||||
};
|
||||
|
||||
const bindHosts = await resolveGatewayListenHosts(params.bindHost);
|
||||
|
||||
@@ -145,8 +145,9 @@ describe("gateway plugin HTTP auth boundary", () => {
|
||||
resolvedAuth: AUTH_TOKEN,
|
||||
overrides: {
|
||||
handlePluginRequest,
|
||||
shouldEnforcePluginGatewayAuth: (requestPath) =>
|
||||
isProtectedPluginRoutePath(requestPath) || requestPath === "/plugin/public",
|
||||
shouldEnforcePluginGatewayAuth: (pathContext) =>
|
||||
isProtectedPluginRoutePath(pathContext.pathname) ||
|
||||
pathContext.pathname === "/plugin/public",
|
||||
},
|
||||
run: async (server) => {
|
||||
const unauthenticated = await sendRequest(server, {
|
||||
@@ -197,8 +198,9 @@ describe("gateway plugin HTTP auth boundary", () => {
|
||||
resolvedAuth: AUTH_TOKEN,
|
||||
overrides: {
|
||||
handlePluginRequest,
|
||||
shouldEnforcePluginGatewayAuth: (requestPath) =>
|
||||
requestPath.startsWith("/api/channels") || requestPath === "/plugin/routed",
|
||||
shouldEnforcePluginGatewayAuth: (pathContext) =>
|
||||
pathContext.pathname.startsWith("/api/channels") ||
|
||||
pathContext.pathname === "/plugin/routed",
|
||||
},
|
||||
run: async (server) => {
|
||||
const unauthenticatedRouted = await sendRequest(server, { path: "/plugin/routed" });
|
||||
@@ -385,7 +387,8 @@ describe("gateway plugin HTTP auth boundary", () => {
|
||||
resolvedAuth: AUTH_TOKEN,
|
||||
overrides: {
|
||||
handlePluginRequest,
|
||||
shouldEnforcePluginGatewayAuth: isProtectedPluginRoutePath,
|
||||
shouldEnforcePluginGatewayAuth: (pathContext) =>
|
||||
isProtectedPluginRoutePath(pathContext.pathname),
|
||||
},
|
||||
run: async (server) => {
|
||||
await expectUnauthorizedVariants({ server, variants: CANONICAL_UNAUTH_VARIANTS });
|
||||
@@ -409,7 +412,8 @@ describe("gateway plugin HTTP auth boundary", () => {
|
||||
resolvedAuth: AUTH_TOKEN,
|
||||
overrides: {
|
||||
handlePluginRequest,
|
||||
shouldEnforcePluginGatewayAuth: isProtectedPluginRoutePath,
|
||||
shouldEnforcePluginGatewayAuth: (pathContext) =>
|
||||
isProtectedPluginRoutePath(pathContext.pathname),
|
||||
},
|
||||
run: async (server) => {
|
||||
for (const variant of buildChannelPathFuzzCorpus()) {
|
||||
|
||||
@@ -5,7 +5,6 @@ export const createTestRegistry = (overrides: Partial<PluginRegistry> = {}): Plu
|
||||
return {
|
||||
...merged,
|
||||
gatewayHandlers: merged.gatewayHandlers ?? {},
|
||||
httpHandlers: merged.httpHandlers ?? [],
|
||||
httpRoutes: merged.httpRoutes ?? [],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -17,11 +17,15 @@ function createPluginLog(): PluginHandlerLog {
|
||||
function createRoute(params: {
|
||||
path: string;
|
||||
pluginId?: string;
|
||||
handler?: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;
|
||||
auth?: "gateway" | "plugin";
|
||||
match?: "exact" | "prefix";
|
||||
handler?: (req: IncomingMessage, res: ServerResponse) => boolean | void | Promise<boolean | void>;
|
||||
}) {
|
||||
return {
|
||||
pluginId: params.pluginId ?? "route",
|
||||
path: params.path,
|
||||
auth: params.auth ?? "gateway",
|
||||
match: params.match ?? "exact",
|
||||
handler: params.handler ?? (() => {}),
|
||||
source: params.pluginId ?? "route",
|
||||
};
|
||||
@@ -36,7 +40,7 @@ function buildRepeatedEncodedSlash(depth: number): string {
|
||||
}
|
||||
|
||||
describe("createGatewayPluginRequestHandler", () => {
|
||||
it("returns false when no handlers are registered", async () => {
|
||||
it("returns false when no routes are registered", async () => {
|
||||
const log = createPluginLog();
|
||||
const handler = createGatewayPluginRequestHandler({
|
||||
registry: createTestRegistry(),
|
||||
@@ -47,35 +51,13 @@ describe("createGatewayPluginRequestHandler", () => {
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
|
||||
it("continues until a handler reports it handled the request", async () => {
|
||||
const first = vi.fn(async () => false);
|
||||
const second = vi.fn(async () => true);
|
||||
const handler = createGatewayPluginRequestHandler({
|
||||
registry: createTestRegistry({
|
||||
httpHandlers: [
|
||||
{ pluginId: "first", handler: first, source: "first" },
|
||||
{ pluginId: "second", handler: second, source: "second" },
|
||||
],
|
||||
}),
|
||||
log: createPluginLog(),
|
||||
});
|
||||
|
||||
const { res } = makeMockHttpResponse();
|
||||
const handled = await handler({} as IncomingMessage, res);
|
||||
expect(handled).toBe(true);
|
||||
expect(first).toHaveBeenCalledTimes(1);
|
||||
expect(second).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("handles registered http routes before generic handlers", async () => {
|
||||
it("handles exact route matches", async () => {
|
||||
const routeHandler = vi.fn(async (_req, res: ServerResponse) => {
|
||||
res.statusCode = 200;
|
||||
});
|
||||
const fallback = vi.fn(async () => true);
|
||||
const handler = createGatewayPluginRequestHandler({
|
||||
registry: createTestRegistry({
|
||||
httpRoutes: [createRoute({ path: "/demo", handler: routeHandler })],
|
||||
httpHandlers: [{ pluginId: "fallback", handler: fallback, source: "fallback" }],
|
||||
}),
|
||||
log: createPluginLog(),
|
||||
});
|
||||
@@ -84,18 +66,57 @@ describe("createGatewayPluginRequestHandler", () => {
|
||||
const handled = await handler({ url: "/demo" } as IncomingMessage, res);
|
||||
expect(handled).toBe(true);
|
||||
expect(routeHandler).toHaveBeenCalledTimes(1);
|
||||
expect(fallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("matches canonicalized route variants before generic handlers", async () => {
|
||||
it("prefers exact matches before prefix matches", async () => {
|
||||
const exactHandler = vi.fn(async (_req, res: ServerResponse) => {
|
||||
res.statusCode = 200;
|
||||
});
|
||||
const prefixHandler = vi.fn(async () => true);
|
||||
const handler = createGatewayPluginRequestHandler({
|
||||
registry: createTestRegistry({
|
||||
httpRoutes: [
|
||||
createRoute({ path: "/api", match: "prefix", handler: prefixHandler }),
|
||||
createRoute({ path: "/api/demo", match: "exact", handler: exactHandler }),
|
||||
],
|
||||
}),
|
||||
log: createPluginLog(),
|
||||
});
|
||||
|
||||
const { res } = makeMockHttpResponse();
|
||||
const handled = await handler({ url: "/api/demo" } as IncomingMessage, res);
|
||||
expect(handled).toBe(true);
|
||||
expect(exactHandler).toHaveBeenCalledTimes(1);
|
||||
expect(prefixHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("supports route fallthrough when handler returns false", async () => {
|
||||
const first = vi.fn(async () => false);
|
||||
const second = vi.fn(async () => true);
|
||||
const handler = createGatewayPluginRequestHandler({
|
||||
registry: createTestRegistry({
|
||||
httpRoutes: [
|
||||
createRoute({ path: "/hook", match: "exact", handler: first }),
|
||||
createRoute({ path: "/hook", match: "prefix", handler: second }),
|
||||
],
|
||||
}),
|
||||
log: createPluginLog(),
|
||||
});
|
||||
|
||||
const { res } = makeMockHttpResponse();
|
||||
const handled = await handler({ url: "/hook" } as IncomingMessage, res);
|
||||
expect(handled).toBe(true);
|
||||
expect(first).toHaveBeenCalledTimes(1);
|
||||
expect(second).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("matches canonicalized route variants", async () => {
|
||||
const routeHandler = vi.fn(async (_req, res: ServerResponse) => {
|
||||
res.statusCode = 200;
|
||||
});
|
||||
const fallback = vi.fn(async () => true);
|
||||
const handler = createGatewayPluginRequestHandler({
|
||||
registry: createTestRegistry({
|
||||
httpRoutes: [createRoute({ path: "/api/demo", handler: routeHandler })],
|
||||
httpHandlers: [{ pluginId: "fallback", handler: fallback, source: "fallback" }],
|
||||
}),
|
||||
log: createPluginLog(),
|
||||
});
|
||||
@@ -104,28 +125,26 @@ describe("createGatewayPluginRequestHandler", () => {
|
||||
const handled = await handler({ url: "/API//demo" } as IncomingMessage, res);
|
||||
expect(handled).toBe(true);
|
||||
expect(routeHandler).toHaveBeenCalledTimes(1);
|
||||
expect(fallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs and responds with 500 when a handler throws", async () => {
|
||||
it("logs and responds with 500 when a route throws", async () => {
|
||||
const log = createPluginLog();
|
||||
const handler = createGatewayPluginRequestHandler({
|
||||
registry: createTestRegistry({
|
||||
httpHandlers: [
|
||||
{
|
||||
pluginId: "boom",
|
||||
httpRoutes: [
|
||||
createRoute({
|
||||
path: "/boom",
|
||||
handler: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
source: "boom",
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
log,
|
||||
});
|
||||
|
||||
const { res, setHeader, end } = makeMockHttpResponse();
|
||||
const handled = await handler({} as IncomingMessage, res);
|
||||
const handled = await handler({ url: "/boom" } as IncomingMessage, res);
|
||||
expect(handled).toBe(true);
|
||||
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("boom"));
|
||||
expect(res.statusCode).toBe(500);
|
||||
@@ -134,7 +153,7 @@ describe("createGatewayPluginRequestHandler", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin HTTP registry helpers", () => {
|
||||
describe("plugin HTTP route auth checks", () => {
|
||||
const deeplyEncodedChannelPath =
|
||||
"/api%2525252fchannels%2525252fnostr%2525252fdefault%2525252fprofile";
|
||||
const decodeOverflowPublicPath = `/googlechat${buildRepeatedEncodedSlash(40)}public`;
|
||||
@@ -156,11 +175,15 @@ describe("plugin HTTP registry helpers", () => {
|
||||
expect(isRegisteredPluginHttpRoutePath(registry, "/api/%2564emo")).toBe(true);
|
||||
});
|
||||
|
||||
it("enforces auth for protected and registered plugin routes", () => {
|
||||
it("enforces auth for protected and gateway-auth routes", () => {
|
||||
const registry = createTestRegistry({
|
||||
httpRoutes: [createRoute({ path: "/api/demo" })],
|
||||
httpRoutes: [
|
||||
createRoute({ path: "/googlechat", match: "prefix", auth: "plugin" }),
|
||||
createRoute({ path: "/api/demo", auth: "gateway" }),
|
||||
],
|
||||
});
|
||||
expect(shouldEnforceGatewayAuthForPluginPath(registry, "/api//demo")).toBe(true);
|
||||
expect(shouldEnforceGatewayAuthForPluginPath(registry, "/googlechat/public")).toBe(false);
|
||||
expect(shouldEnforceGatewayAuthForPluginPath(registry, "/api/channels/status")).toBe(true);
|
||||
expect(shouldEnforceGatewayAuthForPluginPath(registry, deeplyEncodedChannelPath)).toBe(true);
|
||||
expect(shouldEnforceGatewayAuthForPluginPath(registry, decodeOverflowPublicPath)).toBe(true);
|
||||
|
||||
@@ -1,31 +1,117 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import type { PluginRegistry } from "../../plugins/registry.js";
|
||||
import { canonicalizePathVariant } from "../security-path.js";
|
||||
import { hasSecurityPathCanonicalizationAnomaly } from "../security-path.js";
|
||||
import { isProtectedPluginRoutePath } from "../security-path.js";
|
||||
import {
|
||||
PROTECTED_PLUGIN_ROUTE_PREFIXES,
|
||||
canonicalizePathForSecurity,
|
||||
canonicalizePathVariant,
|
||||
} from "../security-path.js";
|
||||
|
||||
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
|
||||
|
||||
export type PluginRoutePathContext = {
|
||||
pathname: string;
|
||||
canonicalPath: string;
|
||||
candidates: string[];
|
||||
malformedEncoding: boolean;
|
||||
decodePassLimitReached: boolean;
|
||||
rawNormalizedPath: string;
|
||||
};
|
||||
|
||||
export type PluginHttpRequestHandler = (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
pathContext?: PluginRoutePathContext,
|
||||
) => Promise<boolean>;
|
||||
|
||||
type PluginHttpRouteEntry = NonNullable<PluginRegistry["httpRoutes"]>[number];
|
||||
|
||||
function normalizeProtectedPrefix(prefix: string): string {
|
||||
const collapsed = prefix.toLowerCase().replace(/\/{2,}/g, "/");
|
||||
if (collapsed.length <= 1) {
|
||||
return collapsed || "/";
|
||||
}
|
||||
return collapsed.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function prefixMatch(pathname: string, prefix: string): boolean {
|
||||
return (
|
||||
pathname === prefix || pathname.startsWith(`${prefix}/`) || pathname.startsWith(`${prefix}%`)
|
||||
);
|
||||
}
|
||||
|
||||
const NORMALIZED_PROTECTED_PLUGIN_ROUTE_PREFIXES =
|
||||
PROTECTED_PLUGIN_ROUTE_PREFIXES.map(normalizeProtectedPrefix);
|
||||
|
||||
export function isProtectedPluginRoutePathFromContext(context: PluginRoutePathContext): boolean {
|
||||
if (
|
||||
context.candidates.some((candidate) =>
|
||||
NORMALIZED_PROTECTED_PLUGIN_ROUTE_PREFIXES.some((prefix) => prefixMatch(candidate, prefix)),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (!context.malformedEncoding) {
|
||||
return false;
|
||||
}
|
||||
return NORMALIZED_PROTECTED_PLUGIN_ROUTE_PREFIXES.some((prefix) =>
|
||||
prefixMatch(context.rawNormalizedPath, prefix),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolvePluginRoutePathContext(pathname: string): PluginRoutePathContext {
|
||||
const canonical = canonicalizePathForSecurity(pathname);
|
||||
return {
|
||||
pathname,
|
||||
canonicalPath: canonical.canonicalPath,
|
||||
candidates: canonical.candidates,
|
||||
malformedEncoding: canonical.malformedEncoding,
|
||||
decodePassLimitReached: canonical.decodePassLimitReached,
|
||||
rawNormalizedPath: canonical.rawNormalizedPath,
|
||||
};
|
||||
}
|
||||
|
||||
function doesRouteMatchPath(route: PluginHttpRouteEntry, context: PluginRoutePathContext): boolean {
|
||||
const routeCanonicalPath = canonicalizePathVariant(route.path);
|
||||
if (route.match === "prefix") {
|
||||
return context.candidates.some((candidate) => prefixMatch(candidate, routeCanonicalPath));
|
||||
}
|
||||
return context.candidates.some((candidate) => candidate === routeCanonicalPath);
|
||||
}
|
||||
|
||||
function findMatchingPluginHttpRoutes(
|
||||
registry: PluginRegistry,
|
||||
context: PluginRoutePathContext,
|
||||
): PluginHttpRouteEntry[] {
|
||||
const routes = registry.httpRoutes ?? [];
|
||||
if (routes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const exactMatches: PluginHttpRouteEntry[] = [];
|
||||
const prefixMatches: PluginHttpRouteEntry[] = [];
|
||||
for (const route of routes) {
|
||||
if (!doesRouteMatchPath(route, context)) {
|
||||
continue;
|
||||
}
|
||||
if (route.match === "prefix") {
|
||||
prefixMatches.push(route);
|
||||
} else {
|
||||
exactMatches.push(route);
|
||||
}
|
||||
}
|
||||
exactMatches.sort((a, b) => b.path.length - a.path.length);
|
||||
prefixMatches.sort((a, b) => b.path.length - a.path.length);
|
||||
return [...exactMatches, ...prefixMatches];
|
||||
}
|
||||
|
||||
export function findRegisteredPluginHttpRoute(
|
||||
registry: PluginRegistry,
|
||||
pathname: string,
|
||||
): PluginHttpRouteEntry | undefined {
|
||||
const canonicalPath = canonicalizePathVariant(pathname);
|
||||
const routes = registry.httpRoutes ?? [];
|
||||
return routes.find((entry) => canonicalizePathVariant(entry.path) === canonicalPath);
|
||||
const pathContext = resolvePluginRoutePathContext(pathname);
|
||||
return findMatchingPluginHttpRoutes(registry, pathContext)[0];
|
||||
}
|
||||
|
||||
// Only checks specific routes registered via registerHttpRoute, not wildcard handlers
|
||||
// registered via registerHttpHandler. Wildcard handlers (e.g., webhooks) implement
|
||||
// their own signature-based auth and are handled separately in the auth enforcement logic.
|
||||
export function isRegisteredPluginHttpRoutePath(
|
||||
registry: PluginRegistry,
|
||||
pathname: string,
|
||||
@@ -35,13 +121,23 @@ export function isRegisteredPluginHttpRoutePath(
|
||||
|
||||
export function shouldEnforceGatewayAuthForPluginPath(
|
||||
registry: PluginRegistry,
|
||||
pathname: string,
|
||||
pathnameOrContext: string | PluginRoutePathContext,
|
||||
): boolean {
|
||||
return (
|
||||
hasSecurityPathCanonicalizationAnomaly(pathname) ||
|
||||
isProtectedPluginRoutePath(pathname) ||
|
||||
isRegisteredPluginHttpRoutePath(registry, pathname)
|
||||
);
|
||||
const pathContext =
|
||||
typeof pathnameOrContext === "string"
|
||||
? resolvePluginRoutePathContext(pathnameOrContext)
|
||||
: pathnameOrContext;
|
||||
if (pathContext.malformedEncoding || pathContext.decodePassLimitReached) {
|
||||
return true;
|
||||
}
|
||||
if (isProtectedPluginRoutePathFromContext(pathContext)) {
|
||||
return true;
|
||||
}
|
||||
const route = findMatchingPluginHttpRoutes(registry, pathContext)[0];
|
||||
if (!route) {
|
||||
return false;
|
||||
}
|
||||
return route.auth === "gateway";
|
||||
}
|
||||
|
||||
export function createGatewayPluginRequestHandler(params: {
|
||||
@@ -49,40 +145,31 @@ export function createGatewayPluginRequestHandler(params: {
|
||||
log: SubsystemLogger;
|
||||
}): PluginHttpRequestHandler {
|
||||
const { registry, log } = params;
|
||||
return async (req, res) => {
|
||||
return async (req, res, providedPathContext) => {
|
||||
const routes = registry.httpRoutes ?? [];
|
||||
const handlers = registry.httpHandlers ?? [];
|
||||
if (routes.length === 0 && handlers.length === 0) {
|
||||
if (routes.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (routes.length > 0) {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
const route = findRegisteredPluginHttpRoute(registry, url.pathname);
|
||||
if (route) {
|
||||
try {
|
||||
await route.handler(req, res);
|
||||
return true;
|
||||
} catch (err) {
|
||||
log.warn(`plugin http route failed (${route.pluginId ?? "unknown"}): ${String(err)}`);
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("Internal Server Error");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const pathContext =
|
||||
providedPathContext ??
|
||||
(() => {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
return resolvePluginRoutePathContext(url.pathname);
|
||||
})();
|
||||
const matchedRoutes = findMatchingPluginHttpRoutes(registry, pathContext);
|
||||
if (matchedRoutes.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const entry of handlers) {
|
||||
for (const route of matchedRoutes) {
|
||||
try {
|
||||
const handled = await entry.handler(req, res);
|
||||
if (handled) {
|
||||
const handled = await route.handler(req, res);
|
||||
if (handled !== false) {
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn(`plugin http handler failed (${entry.pluginId}): ${String(err)}`);
|
||||
log.warn(`plugin http route failed (${route.pluginId ?? "unknown"}): ${String(err)}`);
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
|
||||
@@ -146,7 +146,6 @@ const createStubPluginRegistry = (): PluginRegistry => ({
|
||||
],
|
||||
providers: [],
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
|
||||
@@ -13,7 +13,6 @@ export function createMockPluginRegistry(
|
||||
source: "test",
|
||||
})),
|
||||
tools: [],
|
||||
httpHandlers: [],
|
||||
httpRoutes: [],
|
||||
channelRegistrations: [],
|
||||
gatewayHandlers: {},
|
||||
|
||||
@@ -16,6 +16,8 @@ describe("registerPluginHttpRoute", () => {
|
||||
expect(registry.httpRoutes).toHaveLength(1);
|
||||
expect(registry.httpRoutes[0]?.path).toBe("/plugins/demo");
|
||||
expect(registry.httpRoutes[0]?.handler).toBe(handler);
|
||||
expect(registry.httpRoutes[0]?.auth).toBe("gateway");
|
||||
expect(registry.httpRoutes[0]?.match).toBe("exact");
|
||||
|
||||
unregister();
|
||||
expect(registry.httpRoutes).toHaveLength(0);
|
||||
@@ -64,7 +66,7 @@ describe("registerPluginHttpRoute", () => {
|
||||
expect(registry.httpRoutes).toHaveLength(1);
|
||||
expect(registry.httpRoutes[0]?.handler).toBe(secondHandler);
|
||||
expect(logs).toContain(
|
||||
'plugin: replacing stale webhook path /plugins/synology for account "default" (synology-chat)',
|
||||
'plugin: replacing stale webhook path /plugins/synology (exact) for account "default" (synology-chat)',
|
||||
);
|
||||
|
||||
// Old unregister must not remove the replacement route.
|
||||
|
||||
@@ -6,12 +6,14 @@ import { requireActivePluginRegistry } from "./runtime.js";
|
||||
export type PluginHttpRouteHandler = (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
) => Promise<void> | void;
|
||||
) => Promise<boolean | void> | boolean | void;
|
||||
|
||||
export function registerPluginHttpRoute(params: {
|
||||
path?: string | null;
|
||||
fallbackPath?: string | null;
|
||||
handler: PluginHttpRouteHandler;
|
||||
auth?: PluginHttpRouteRegistration["auth"];
|
||||
match?: PluginHttpRouteRegistration["match"];
|
||||
pluginId?: string;
|
||||
source?: string;
|
||||
accountId?: string;
|
||||
@@ -29,16 +31,23 @@ export function registerPluginHttpRoute(params: {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const existingIndex = routes.findIndex((entry) => entry.path === normalizedPath);
|
||||
const routeMatch = params.match ?? "exact";
|
||||
const existingIndex = routes.findIndex(
|
||||
(entry) => entry.path === normalizedPath && entry.match === routeMatch,
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
const pluginHint = params.pluginId ? ` (${params.pluginId})` : "";
|
||||
params.log?.(`plugin: replacing stale webhook path ${normalizedPath}${suffix}${pluginHint}`);
|
||||
params.log?.(
|
||||
`plugin: replacing stale webhook path ${normalizedPath} (${routeMatch})${suffix}${pluginHint}`,
|
||||
);
|
||||
routes.splice(existingIndex, 1);
|
||||
}
|
||||
|
||||
const entry: PluginHttpRouteRegistration = {
|
||||
path: normalizedPath,
|
||||
handler: params.handler,
|
||||
auth: params.auth ?? "gateway",
|
||||
match: routeMatch,
|
||||
pluginId: params.pluginId,
|
||||
source: params.source,
|
||||
};
|
||||
|
||||
@@ -518,13 +518,18 @@ describe("loadOpenClawPlugins", () => {
|
||||
expect(channel).toBeDefined();
|
||||
});
|
||||
|
||||
it("registers http handlers", () => {
|
||||
it("registers http routes with auth and match options", () => {
|
||||
useNoBundledPlugins();
|
||||
const plugin = writePlugin({
|
||||
id: "http-demo",
|
||||
filename: "http-demo.cjs",
|
||||
body: `module.exports = { id: "http-demo", register(api) {
|
||||
api.registerHttpHandler(async () => false);
|
||||
api.registerHttpRoute({
|
||||
path: "/webhook",
|
||||
auth: "plugin",
|
||||
match: "prefix",
|
||||
handler: async () => false
|
||||
});
|
||||
} };`,
|
||||
});
|
||||
|
||||
@@ -535,10 +540,13 @@ describe("loadOpenClawPlugins", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const handler = registry.httpHandlers.find((entry) => entry.pluginId === "http-demo");
|
||||
expect(handler).toBeDefined();
|
||||
const route = registry.httpRoutes.find((entry) => entry.pluginId === "http-demo");
|
||||
expect(route).toBeDefined();
|
||||
expect(route?.path).toBe("/webhook");
|
||||
expect(route?.auth).toBe("plugin");
|
||||
expect(route?.match).toBe("prefix");
|
||||
const httpPlugin = registry.plugins.find((entry) => entry.id === "http-demo");
|
||||
expect(httpPlugin?.httpHandlers).toBe(1);
|
||||
expect(httpPlugin?.httpRoutes).toBe(1);
|
||||
});
|
||||
|
||||
it("registers http routes", () => {
|
||||
@@ -561,8 +569,10 @@ describe("loadOpenClawPlugins", () => {
|
||||
const route = registry.httpRoutes.find((entry) => entry.pluginId === "http-route-demo");
|
||||
expect(route).toBeDefined();
|
||||
expect(route?.path).toBe("/demo");
|
||||
expect(route?.auth).toBe("gateway");
|
||||
expect(route?.match).toBe("exact");
|
||||
const httpPlugin = registry.plugins.find((entry) => entry.id === "http-route-demo");
|
||||
expect(httpPlugin?.httpHandlers).toBe(1);
|
||||
expect(httpPlugin?.httpRoutes).toBe(1);
|
||||
});
|
||||
|
||||
it("respects explicit disable in config", () => {
|
||||
|
||||
@@ -176,7 +176,7 @@ function createPluginRecord(params: {
|
||||
cliCommands: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
httpHandlers: 0,
|
||||
httpRoutes: 0,
|
||||
hookCount: 0,
|
||||
configSchema: params.configSchema,
|
||||
configUiHints: undefined,
|
||||
|
||||
@@ -17,8 +17,10 @@ import type {
|
||||
OpenClawPluginChannelRegistration,
|
||||
OpenClawPluginCliRegistrar,
|
||||
OpenClawPluginCommandDefinition,
|
||||
OpenClawPluginHttpHandler,
|
||||
OpenClawPluginHttpRouteAuth,
|
||||
OpenClawPluginHttpRouteMatch,
|
||||
OpenClawPluginHttpRouteHandler,
|
||||
OpenClawPluginHttpRouteParams,
|
||||
OpenClawPluginHookOptions,
|
||||
ProviderPlugin,
|
||||
OpenClawPluginService,
|
||||
@@ -49,16 +51,12 @@ export type PluginCliRegistration = {
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type PluginHttpRegistration = {
|
||||
pluginId: string;
|
||||
handler: OpenClawPluginHttpHandler;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type PluginHttpRouteRegistration = {
|
||||
pluginId?: string;
|
||||
path: string;
|
||||
handler: OpenClawPluginHttpRouteHandler;
|
||||
auth: OpenClawPluginHttpRouteAuth;
|
||||
match: OpenClawPluginHttpRouteMatch;
|
||||
source?: string;
|
||||
};
|
||||
|
||||
@@ -114,7 +112,7 @@ export type PluginRecord = {
|
||||
cliCommands: string[];
|
||||
services: string[];
|
||||
commands: string[];
|
||||
httpHandlers: number;
|
||||
httpRoutes: number;
|
||||
hookCount: number;
|
||||
configSchema: boolean;
|
||||
configUiHints?: Record<string, PluginConfigUiHint>;
|
||||
@@ -129,7 +127,6 @@ export type PluginRegistry = {
|
||||
channels: PluginChannelRegistration[];
|
||||
providers: PluginProviderRegistration[];
|
||||
gatewayHandlers: GatewayRequestHandlers;
|
||||
httpHandlers: PluginHttpRegistration[];
|
||||
httpRoutes: PluginHttpRouteRegistration[];
|
||||
cliRegistrars: PluginCliRegistration[];
|
||||
services: PluginServiceRegistration[];
|
||||
@@ -152,7 +149,6 @@ export function createEmptyPluginRegistry(): PluginRegistry {
|
||||
channels: [],
|
||||
providers: [],
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
@@ -288,19 +284,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
record.gatewayMethods.push(trimmed);
|
||||
};
|
||||
|
||||
const registerHttpHandler = (record: PluginRecord, handler: OpenClawPluginHttpHandler) => {
|
||||
record.httpHandlers += 1;
|
||||
registry.httpHandlers.push({
|
||||
pluginId: record.id,
|
||||
handler,
|
||||
source: record.source,
|
||||
});
|
||||
};
|
||||
|
||||
const registerHttpRoute = (
|
||||
record: PluginRecord,
|
||||
params: { path: string; handler: OpenClawPluginHttpRouteHandler },
|
||||
) => {
|
||||
const registerHttpRoute = (record: PluginRecord, params: OpenClawPluginHttpRouteParams) => {
|
||||
const normalizedPath = normalizePluginHttpPath(params.path);
|
||||
if (!normalizedPath) {
|
||||
pushDiagnostic({
|
||||
@@ -311,20 +295,25 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (registry.httpRoutes.some((entry) => entry.path === normalizedPath)) {
|
||||
const match = params.match ?? "exact";
|
||||
if (
|
||||
registry.httpRoutes.some((entry) => entry.path === normalizedPath && entry.match === match)
|
||||
) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: `http route already registered: ${normalizedPath}`,
|
||||
message: `http route already registered: ${normalizedPath} (${match})`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
record.httpHandlers += 1;
|
||||
record.httpRoutes += 1;
|
||||
registry.httpRoutes.push({
|
||||
pluginId: record.id,
|
||||
path: normalizedPath,
|
||||
handler: params.handler,
|
||||
auth: params.auth ?? "gateway",
|
||||
match,
|
||||
source: record.source,
|
||||
});
|
||||
};
|
||||
@@ -489,7 +478,6 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
registerTool: (tool, opts) => registerTool(record, tool, opts),
|
||||
registerHook: (events, handler, opts) =>
|
||||
registerHook(record, events, handler, opts, params.config),
|
||||
registerHttpHandler: (handler) => registerHttpHandler(record, handler),
|
||||
registerHttpRoute: (params) => registerHttpRoute(record, params),
|
||||
registerChannel: (registration) => registerChannel(record, registration),
|
||||
registerProvider: (provider) => registerProvider(record, provider),
|
||||
|
||||
@@ -194,15 +194,20 @@ export type OpenClawPluginCommandDefinition = {
|
||||
handler: PluginCommandHandler;
|
||||
};
|
||||
|
||||
export type OpenClawPluginHttpHandler = (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
) => Promise<boolean> | boolean;
|
||||
export type OpenClawPluginHttpRouteAuth = "gateway" | "plugin";
|
||||
export type OpenClawPluginHttpRouteMatch = "exact" | "prefix";
|
||||
|
||||
export type OpenClawPluginHttpRouteHandler = (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
) => Promise<void> | void;
|
||||
) => Promise<boolean | void> | boolean | void;
|
||||
|
||||
export type OpenClawPluginHttpRouteParams = {
|
||||
path: string;
|
||||
handler: OpenClawPluginHttpRouteHandler;
|
||||
auth?: OpenClawPluginHttpRouteAuth;
|
||||
match?: OpenClawPluginHttpRouteMatch;
|
||||
};
|
||||
|
||||
export type OpenClawPluginCliContext = {
|
||||
program: Command;
|
||||
@@ -265,8 +270,7 @@ export type OpenClawPluginApi = {
|
||||
handler: InternalHookHandler,
|
||||
opts?: OpenClawPluginHookOptions,
|
||||
) => void;
|
||||
registerHttpHandler: (handler: OpenClawPluginHttpHandler) => void;
|
||||
registerHttpRoute: (params: { path: string; handler: OpenClawPluginHttpRouteHandler }) => void;
|
||||
registerHttpRoute: (params: OpenClawPluginHttpRouteParams) => void;
|
||||
registerChannel: (registration: OpenClawPluginChannelRegistration | ChannelPlugin) => void;
|
||||
registerGatewayMethod: (method: string, handler: GatewayRequestHandler) => void;
|
||||
registerCli: (registrar: OpenClawPluginCliRegistrar, opts?: { commands?: string[] }) => void;
|
||||
|
||||
@@ -20,7 +20,6 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl
|
||||
channels: channels as unknown as PluginRegistry["channels"],
|
||||
providers: [],
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
|
||||
Reference in New Issue
Block a user