refactor(gateway): harden plugin http route contracts

This commit is contained in:
Peter Steinberger
2026-03-02 16:47:51 +00:00
parent 33e76db12a
commit 7a7eee920a
23 changed files with 642 additions and 270 deletions

View File

@@ -56,6 +56,7 @@ Docs: https://docs.openclaw.ai
- Zalouser/Pairing auth tests: add account-scoped DM pairing-store regression coverage (`monitor.account-scope.test.ts`) to prevent cross-account allowlist bleed in multi-account setups. (#26672) Thanks @bmendonca3. - Zalouser/Pairing auth tests: add account-scoped DM pairing-store regression coverage (`monitor.account-scope.test.ts`) to prevent cross-account allowlist bleed in multi-account setups. (#26672) Thanks @bmendonca3.
- Security/Web tools SSRF guard: keep DNS pinning for untrusted `web_fetch` and citation-redirect URL checks when proxy env vars are set, and require explicit dangerous opt-in before env-proxy routing can bypass pinned dispatch for trusted/operator-controlled endpoints. Thanks @tdjackey for reporting. - Security/Web tools SSRF guard: keep DNS pinning for untrusted `web_fetch` and citation-redirect URL checks when proxy env vars are set, and require explicit dangerous opt-in before env-proxy routing can bypass pinned dispatch for trusted/operator-controlled endpoints. Thanks @tdjackey for reporting.
- Gateway/Security canonicalization hardening: decode plugin route path variants to canonical fixpoint (with bounded depth), fail closed on canonicalization anomalies, and enforce gateway auth for deeply encoded `/api/channels/*` variants to prevent alternate-path auth bypass through plugin handlers. Thanks @tdjackey for reporting. - Gateway/Security canonicalization hardening: decode plugin route path variants to canonical fixpoint (with bounded depth), fail closed on canonicalization anomalies, and enforce gateway auth for deeply encoded `/api/channels/*` variants to prevent alternate-path auth bypass through plugin handlers. Thanks @tdjackey for reporting.
- Gateway/Plugin HTTP hardening: require explicit `auth` for plugin route registration, add route ownership guards for duplicate `path+match` registrations, centralize plugin path matching/auth logic into dedicated modules, and share webhook target-route lifecycle wiring across channel monitors to avoid stale or conflicting registrations. Thanks @tdjackey for reporting.
- Agents/Sessions list transcript paths: handle missing/non-string/relative `sessions.list.path` values and per-agent `{agentId}` templates when deriving `transcriptPath`, so cross-agent session listings resolve to concrete agent session files instead of workspace-relative paths. (#24775) Thanks @martinfrancois. - Agents/Sessions list transcript paths: handle missing/non-string/relative `sessions.list.path` values and per-agent `{agentId}` templates when deriving `transcriptPath`, so cross-agent session listings resolve to concrete agent session files instead of workspace-relative paths. (#24775) Thanks @martinfrancois.
- Agents/Subagents `sessions_spawn`: reject malformed `agentId` inputs before normalization (for example error-message/path-like strings) to prevent unintended synthetic agent IDs and ghost workspace/session paths; includes strict validation regression coverage. (#31381) Thanks @openperf. - Agents/Subagents `sessions_spawn`: reject malformed `agentId` inputs before normalization (for example error-message/path-like strings) to prevent unintended synthetic agent IDs and ghost workspace/session paths; includes strict validation regression coverage. (#31381) Thanks @openperf.
- macOS/PeekabooBridge: add compatibility socket symlinks for legacy `clawdbot`, `clawdis`, and `moltbot` Application Support socket paths so pre-rename clients can still connect. (#6033) Thanks @lumpinif and @vincentkoc. - macOS/PeekabooBridge: add compatibility socket symlinks for legacy `clawdbot`, `clawdis`, and `moltbot` Application Support socket paths so pre-rename clients can still connect. (#6033) Thanks @lumpinif and @vincentkoc.

View File

@@ -4,8 +4,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { import {
isRequestBodyLimitError, isRequestBodyLimitError,
readRequestBodyWithLimit, readRequestBodyWithLimit,
registerPluginHttpRoute, registerWebhookTargetWithPluginRoute,
registerWebhookTarget,
rejectNonPostWebhookRequest, rejectNonPostWebhookRequest,
requestBodyErrorToText, requestBodyErrorToText,
resolveSingleWebhookTarget, resolveSingleWebhookTarget,
@@ -236,23 +235,25 @@ function removeDebouncer(target: WebhookTarget): void {
} }
export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void { export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void {
const registered = registerWebhookTarget(webhookTargets, target, { const registered = registerWebhookTargetWithPluginRoute({
onFirstPathTarget: ({ path }) => targetsByPath: webhookTargets,
registerPluginHttpRoute({ target,
path, route: {
pluginId: "bluebubbles", auth: "plugin",
source: "bluebubbles-webhook", match: "exact",
accountId: target.account.accountId, pluginId: "bluebubbles",
log: target.runtime.log, source: "bluebubbles-webhook",
handler: async (req, res) => { accountId: target.account.accountId,
const handled = await handleBlueBubblesWebhookRequest(req, res); log: target.runtime.log,
if (!handled && !res.headersSent) { handler: async (req, res) => {
res.statusCode = 404; const handled = await handleBlueBubblesWebhookRequest(req, res);
res.setHeader("Content-Type", "text/plain; charset=utf-8"); if (!handled && !res.headersSent) {
res.end("Not Found"); res.statusCode = 404;
} res.setHeader("Content-Type", "text/plain; charset=utf-8");
}, res.end("Not Found");
}), }
},
},
}); });
return () => { return () => {
registered.unregister(); registered.unregister();
@@ -530,20 +531,10 @@ export async function monitorBlueBubblesProvider(
path, path,
statusSink, 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) => { return await new Promise((resolve) => {
const stop = () => { const stop = () => {
unregister(); unregister();
unregisterRoute();
resolve(); resolve();
}; };

View File

@@ -5,9 +5,7 @@ import {
createScopedPairingAccess, createScopedPairingAccess,
createReplyPrefixOptions, createReplyPrefixOptions,
readJsonBodyWithLimit, readJsonBodyWithLimit,
registerPluginHttpRoute, registerWebhookTargetWithPluginRoute,
registerWebhookTarget,
registerPluginHttpRoute,
rejectNonPostWebhookRequest, rejectNonPostWebhookRequest,
isDangerousNameMatchingEnabled, isDangerousNameMatchingEnabled,
resolveAllowlistProviderRuntimeGroupPolicy, resolveAllowlistProviderRuntimeGroupPolicy,
@@ -102,23 +100,25 @@ function warnDeprecatedUsersEmailEntries(
} }
export function registerGoogleChatWebhookTarget(target: WebhookTarget): () => void { export function registerGoogleChatWebhookTarget(target: WebhookTarget): () => void {
return registerWebhookTarget(webhookTargets, target, { return registerWebhookTargetWithPluginRoute({
onFirstPathTarget: ({ path }) => targetsByPath: webhookTargets,
registerPluginHttpRoute({ target,
path, route: {
pluginId: "googlechat", auth: "plugin",
source: "googlechat-webhook", match: "exact",
accountId: target.account.accountId, pluginId: "googlechat",
log: target.runtime.log, source: "googlechat-webhook",
handler: async (req, res) => { accountId: target.account.accountId,
const handled = await handleGoogleChatWebhookRequest(req, res); log: target.runtime.log,
if (!handled && !res.headersSent) { handler: async (req, res) => {
res.statusCode = 404; const handled = await handleGoogleChatWebhookRequest(req, res);
res.setHeader("Content-Type", "text/plain; charset=utf-8"); if (!handled && !res.headersSent) {
res.end("Not Found"); res.statusCode = 404;
} res.setHeader("Content-Type", "text/plain; charset=utf-8");
}, res.end("Not Found");
}), }
},
},
}).unregister; }).unregister;
} }
@@ -981,19 +981,9 @@ export function monitorGoogleChatProvider(options: GoogleChatMonitorOptions): ()
statusSink: options.statusSink, statusSink: options.statusSink,
mediaMaxMb, 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 () => { return () => {
unregisterTarget(); unregisterTarget();
unregisterRoute();
}; };
} }

View File

@@ -33,7 +33,6 @@ function createApi(params: {
logger: { info() {}, warn() {}, error() {} }, logger: { info() {}, warn() {}, error() {} },
registerTool() {}, registerTool() {},
registerHook() {}, registerHook() {},
registerHttpHandler() {},
registerHttpRoute() {}, registerHttpRoute() {},
registerChannel() {}, registerChannel() {},
registerGatewayMethod() {}, registerGatewayMethod() {},

View File

@@ -295,6 +295,8 @@ export function createSynologyChatPlugin() {
const unregister = registerPluginHttpRoute({ const unregister = registerPluginHttpRoute({
path: account.webhookPath, path: account.webhookPath,
auth: "plugin",
replaceExisting: true,
pluginId: CHANNEL_ID, pluginId: CHANNEL_ID,
accountId: account.accountId, accountId: account.accountId,
log: (msg: string) => log?.info?.(msg), log: (msg: string) => log?.info?.(msg),

View File

@@ -3,7 +3,6 @@ import type { MarkdownTableMode, OpenClawConfig, OutboundReplyPayload } from "op
import { import {
createScopedPairingAccess, createScopedPairingAccess,
createReplyPrefixOptions, createReplyPrefixOptions,
registerPluginHttpRoute,
resolveDirectDmAuthorizationOutcome, resolveDirectDmAuthorizationOutcome,
resolveSenderCommandAuthorizationWithRuntime, resolveSenderCommandAuthorizationWithRuntime,
resolveOutboundMediaUrls, resolveOutboundMediaUrls,
@@ -77,22 +76,22 @@ function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: str
export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void { export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void {
return registerZaloWebhookTargetInternal(target, { return registerZaloWebhookTargetInternal(target, {
onFirstPathTarget: ({ path }) => route: {
registerPluginHttpRoute({ auth: "plugin",
path, match: "exact",
pluginId: "zalo", pluginId: "zalo",
source: "zalo-webhook", source: "zalo-webhook",
accountId: target.account.accountId, accountId: target.account.accountId,
log: target.runtime.log, log: target.runtime.log,
handler: async (req, res) => { handler: async (req, res) => {
const handled = await handleZaloWebhookRequest(req, res); const handled = await handleZaloWebhookRequest(req, res);
if (!handled && !res.headersSent) { if (!handled && !res.headersSent) {
res.statusCode = 404; res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not Found"); res.end("Not Found");
} }
}, },
}), },
}); });
} }
@@ -653,17 +652,7 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<
mediaMaxMb: effectiveMediaMaxMb, mediaMaxMb: effectiveMediaMaxMb,
fetcher, 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(unregister);
stopHandlers.push(unregisterRoute);
abortSignal.addEventListener( abortSignal.addEventListener(
"abort", "abort",
() => { () => {

View File

@@ -7,7 +7,9 @@ import {
createWebhookAnomalyTracker, createWebhookAnomalyTracker,
readJsonWebhookBodyOrReject, readJsonWebhookBodyOrReject,
applyBasicWebhookRequestGuards, applyBasicWebhookRequestGuards,
registerWebhookTargetWithPluginRoute,
type RegisterWebhookTargetOptions, type RegisterWebhookTargetOptions,
type RegisterWebhookPluginRouteOptions,
registerWebhookTarget, registerWebhookTarget,
resolveSingleWebhookTarget, resolveSingleWebhookTarget,
resolveWebhookTargets, resolveWebhookTargets,
@@ -109,11 +111,21 @@ function recordWebhookStatus(
export function registerZaloWebhookTarget( export function registerZaloWebhookTarget(
target: ZaloWebhookTarget, target: ZaloWebhookTarget,
opts?: Pick< opts?: {
route?: RegisterWebhookPluginRouteOptions;
} & Pick<
RegisterWebhookTargetOptions<ZaloWebhookTarget>, RegisterWebhookTargetOptions<ZaloWebhookTarget>,
"onFirstPathTarget" | "onLastPathTargetRemoved" "onFirstPathTarget" | "onLastPathTargetRemoved"
>, >,
): () => void { ): () => void {
if (opts?.route) {
return registerWebhookTargetWithPluginRoute({
targetsByPath: webhookTargets,
target,
route: opts.route,
onLastPathTargetRemoved: opts.onLastPathTargetRemoved,
}).unregister;
}
return registerWebhookTarget(webhookTargets, target, opts).unregister; return registerWebhookTarget(webhookTargets, target, opts).unregister;
} }

View File

@@ -128,7 +128,7 @@ export async function sendRequest(
authorization?: string; authorization?: string;
method?: string; method?: string;
}, },
) { ): Promise<ReturnType<typeof createResponse>> {
const response = createResponse(); const response = createResponse();
await dispatchRequest(server, createRequest(params), response.res); await dispatchRequest(server, createRequest(params), response.res);
return response; return response;

View File

@@ -170,6 +170,59 @@ async function runGatewayHttpRequestStages(
return false; return false;
} }
function buildPluginRequestStages(params: {
req: IncomingMessage;
res: ServerResponse;
requestPath: string;
pluginPathContext: PluginRoutePathContext | null;
handlePluginRequest?: PluginHttpRequestHandler;
shouldEnforcePluginGatewayAuth?: (pathContext: PluginRoutePathContext) => boolean;
resolvedAuth: ResolvedGatewayAuth;
trustedProxies: string[];
allowRealIpFallback: boolean;
rateLimiter?: AuthRateLimiter;
}): GatewayHttpRequestStage[] {
if (!params.handlePluginRequest) {
return [];
}
return [
{
name: "plugin-auth",
run: async () => {
const pathContext =
params.pluginPathContext ?? resolvePluginRoutePathContext(params.requestPath);
if (
!(params.shouldEnforcePluginGatewayAuth ?? shouldEnforceDefaultPluginGatewayAuth)(
pathContext,
)
) {
return false;
}
const pluginAuthOk = await enforcePluginRouteGatewayAuth({
req: params.req,
res: params.res,
auth: params.resolvedAuth,
trustedProxies: params.trustedProxies,
allowRealIpFallback: params.allowRealIpFallback,
rateLimiter: params.rateLimiter,
});
if (!pluginAuthOk) {
return true;
}
return false;
},
},
{
name: "plugin-http",
run: () => {
const pathContext =
params.pluginPathContext ?? resolvePluginRoutePathContext(params.requestPath);
return params.handlePluginRequest?.(params.req, params.res, pathContext) ?? false;
},
},
];
}
export function createHooksRequestHandler( export function createHooksRequestHandler(
opts: { opts: {
getHooksConfig: () => HooksConfigResolved | null; getHooksConfig: () => HooksConfigResolved | null;
@@ -555,40 +608,20 @@ export function createGatewayHttpServer(opts: {
} }
// Plugins run after built-in gateway routes so core surfaces keep // Plugins run after built-in gateway routes so core surfaces keep
// precedence on overlapping paths. // precedence on overlapping paths.
if (handlePluginRequest) { requestStages.push(
requestStages.push({ ...buildPluginRequestStages({
name: "plugin-auth", req,
run: async () => { res,
const pathContext = pluginPathContext ?? resolvePluginRoutePathContext(requestPath); requestPath,
if ( pluginPathContext,
!(shouldEnforcePluginGatewayAuth ?? shouldEnforceDefaultPluginGatewayAuth)( handlePluginRequest,
pathContext, shouldEnforcePluginGatewayAuth,
) resolvedAuth,
) { trustedProxies,
return false; allowRealIpFallback,
} rateLimiter,
const pluginAuthOk = await enforcePluginRouteGatewayAuth({ }),
req, );
res,
auth: resolvedAuth,
trustedProxies,
allowRealIpFallback,
rateLimiter,
});
if (!pluginAuthOk) {
return true;
}
return false;
},
});
requestStages.push({
name: "plugin-http",
run: () => {
const pathContext = pluginPathContext ?? resolvePluginRoutePathContext(requestPath);
return handlePluginRequest(req, res, pathContext);
},
});
}
requestStages.push({ requestStages.push({
name: "gateway-probes", name: "gateway-probes",

View File

@@ -418,11 +418,36 @@ describe("gateway plugin HTTP auth boundary", () => {
run: async (server) => { run: async (server) => {
for (const variant of buildChannelPathFuzzCorpus()) { for (const variant of buildChannelPathFuzzCorpus()) {
const response = await sendRequest(server, { path: variant.path }); const response = await sendRequest(server, { path: variant.path });
expect(response.res.statusCode, variant.label).not.toBe(200); expect(response.res.statusCode, variant.label).toBe(401);
expect(response.getBody(), variant.label).not.toContain( expect(response.getBody(), variant.label).toContain("Unauthorized");
'"route":"channel-canonicalized"',
);
} }
expect(handlePluginRequest).not.toHaveBeenCalled();
},
});
});
test("enforces auth before plugin handlers on encoded protected-path variants", async () => {
const encodedVariants = buildChannelPathFuzzCorpus().filter((variant) =>
variant.path.includes("%"),
);
const handlePluginRequest = vi.fn(async (_req: IncomingMessage, res: ServerResponse) => {
res.statusCode = 200;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify({ ok: true, route: "should-not-run" }));
return true;
});
await withGatewayServer({
prefix: "openclaw-plugin-http-auth-encoded-order-test-",
resolvedAuth: AUTH_TOKEN,
overrides: { handlePluginRequest },
run: async (server) => {
for (const variant of encodedVariants) {
const response = await sendRequest(server, { path: variant.path });
expect(response.res.statusCode, variant.label).toBe(401);
expect(response.getBody(), variant.label).toContain("Unauthorized");
}
expect(handlePluginRequest).not.toHaveBeenCalled();
}, },
}); });
}); });

View File

@@ -2,144 +2,30 @@ import type { IncomingMessage, ServerResponse } from "node:http";
import type { createSubsystemLogger } from "../../logging/subsystem.js"; import type { createSubsystemLogger } from "../../logging/subsystem.js";
import type { PluginRegistry } from "../../plugins/registry.js"; import type { PluginRegistry } from "../../plugins/registry.js";
import { import {
PROTECTED_PLUGIN_ROUTE_PREFIXES, resolvePluginRoutePathContext,
canonicalizePathForSecurity, type PluginRoutePathContext,
canonicalizePathVariant, } from "./plugins-http/path-context.js";
} from "../security-path.js"; import { findMatchingPluginHttpRoutes } from "./plugins-http/route-match.js";
export {
isProtectedPluginRoutePathFromContext,
resolvePluginRoutePathContext,
type PluginRoutePathContext,
} from "./plugins-http/path-context.js";
export {
findRegisteredPluginHttpRoute,
isRegisteredPluginHttpRoutePath,
} from "./plugins-http/route-match.js";
export { shouldEnforceGatewayAuthForPluginPath } from "./plugins-http/route-auth.js";
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>; type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
export type PluginRoutePathContext = {
pathname: string;
canonicalPath: string;
candidates: string[];
malformedEncoding: boolean;
decodePassLimitReached: boolean;
rawNormalizedPath: string;
};
export type PluginHttpRequestHandler = ( export type PluginHttpRequestHandler = (
req: IncomingMessage, req: IncomingMessage,
res: ServerResponse, res: ServerResponse,
pathContext?: PluginRoutePathContext, pathContext?: PluginRoutePathContext,
) => Promise<boolean>; ) => 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 pathContext = resolvePluginRoutePathContext(pathname);
return findMatchingPluginHttpRoutes(registry, pathContext)[0];
}
export function isRegisteredPluginHttpRoutePath(
registry: PluginRegistry,
pathname: string,
): boolean {
return findRegisteredPluginHttpRoute(registry, pathname) !== undefined;
}
export function shouldEnforceGatewayAuthForPluginPath(
registry: PluginRegistry,
pathnameOrContext: string | PluginRoutePathContext,
): boolean {
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: { export function createGatewayPluginRequestHandler(params: {
registry: PluginRegistry; registry: PluginRegistry;
log: SubsystemLogger; log: SubsystemLogger;

View File

@@ -0,0 +1,60 @@
import {
PROTECTED_PLUGIN_ROUTE_PREFIXES,
canonicalizePathForSecurity,
} from "../../security-path.js";
export type PluginRoutePathContext = {
pathname: string;
canonicalPath: string;
candidates: string[];
malformedEncoding: boolean;
decodePassLimitReached: boolean;
rawNormalizedPath: string;
};
function normalizeProtectedPrefix(prefix: string): string {
const collapsed = prefix.toLowerCase().replace(/\/{2,}/g, "/");
if (collapsed.length <= 1) {
return collapsed || "/";
}
return collapsed.replace(/\/+$/, "");
}
export function prefixMatchPath(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) =>
prefixMatchPath(candidate, prefix),
),
)
) {
return true;
}
if (!context.malformedEncoding) {
return false;
}
return NORMALIZED_PROTECTED_PLUGIN_ROUTE_PREFIXES.some((prefix) =>
prefixMatchPath(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,
};
}

View File

@@ -0,0 +1,28 @@
import type { PluginRegistry } from "../../../plugins/registry.js";
import {
isProtectedPluginRoutePathFromContext,
resolvePluginRoutePathContext,
type PluginRoutePathContext,
} from "./path-context.js";
import { findMatchingPluginHttpRoutes } from "./route-match.js";
export function shouldEnforceGatewayAuthForPluginPath(
registry: PluginRegistry,
pathnameOrContext: string | PluginRoutePathContext,
): boolean {
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";
}

View File

@@ -0,0 +1,60 @@
import type { PluginRegistry } from "../../../plugins/registry.js";
import { canonicalizePathVariant } from "../../security-path.js";
import {
prefixMatchPath,
resolvePluginRoutePathContext,
type PluginRoutePathContext,
} from "./path-context.js";
type PluginHttpRouteEntry = NonNullable<PluginRegistry["httpRoutes"]>[number];
export function doesPluginRouteMatchPath(
route: PluginHttpRouteEntry,
context: PluginRoutePathContext,
): boolean {
const routeCanonicalPath = canonicalizePathVariant(route.path);
if (route.match === "prefix") {
return context.candidates.some((candidate) => prefixMatchPath(candidate, routeCanonicalPath));
}
return context.candidates.some((candidate) => candidate === routeCanonicalPath);
}
export 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 (!doesPluginRouteMatchPath(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 pathContext = resolvePluginRoutePathContext(pathname);
return findMatchingPluginHttpRoutes(registry, pathContext)[0];
}
export function isRegisteredPluginHttpRoutePath(
registry: PluginRegistry,
pathname: string,
): boolean {
return findRegisteredPluginHttpRoute(registry, pathname) !== undefined;
}

View File

@@ -289,6 +289,7 @@ export async function monitorLineProvider(
const unregisterHttp = registerPluginHttpRoute({ const unregisterHttp = registerPluginHttpRoute({
path: normalizedPath, path: normalizedPath,
auth: "plugin", auth: "plugin",
replaceExisting: true,
pluginId: "line", pluginId: "line",
accountId: resolvedAccountId, accountId: resolvedAccountId,
log: (msg) => logVerbose(msg), log: (msg) => logVerbose(msg),

View File

@@ -123,12 +123,17 @@ export { acquireFileLock, withFileLock } from "./file-lock.js";
export { normalizeWebhookPath, resolveWebhookPath } from "./webhook-path.js"; export { normalizeWebhookPath, resolveWebhookPath } from "./webhook-path.js";
export { export {
registerWebhookTarget, registerWebhookTarget,
registerWebhookTargetWithPluginRoute,
rejectNonPostWebhookRequest, rejectNonPostWebhookRequest,
resolveSingleWebhookTarget, resolveSingleWebhookTarget,
resolveSingleWebhookTargetAsync, resolveSingleWebhookTargetAsync,
resolveWebhookTargets, resolveWebhookTargets,
} from "./webhook-targets.js"; } from "./webhook-targets.js";
export type { RegisterWebhookTargetOptions, WebhookTargetMatchResult } from "./webhook-targets.js"; export type {
RegisterWebhookPluginRouteOptions,
RegisterWebhookTargetOptions,
WebhookTargetMatchResult,
} from "./webhook-targets.js";
export { export {
applyBasicWebhookRequestGuards, applyBasicWebhookRequestGuards,
isJsonContentType, isJsonContentType,

View File

@@ -1,8 +1,11 @@
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import { describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { createEmptyPluginRegistry } from "../plugins/registry.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { import {
registerWebhookTarget, registerWebhookTarget,
registerWebhookTargetWithPluginRoute,
rejectNonPostWebhookRequest, rejectNonPostWebhookRequest,
resolveSingleWebhookTarget, resolveSingleWebhookTarget,
resolveSingleWebhookTargetAsync, resolveSingleWebhookTargetAsync,
@@ -17,6 +20,10 @@ function createRequest(method: string, url: string): IncomingMessage {
return req; return req;
} }
afterEach(() => {
setActivePluginRegistry(createEmptyPluginRegistry());
});
describe("registerWebhookTarget", () => { describe("registerWebhookTarget", () => {
it("normalizes the path and unregisters cleanly", () => { it("normalizes the path and unregisters cleanly", () => {
const targets = new Map<string, Array<{ path: string; id: string }>>(); const targets = new Map<string, Array<{ path: string; id: string }>>();
@@ -86,6 +93,49 @@ describe("registerWebhookTarget", () => {
}); });
}); });
describe("registerWebhookTargetWithPluginRoute", () => {
it("registers plugin route on first target and removes it on last target", () => {
const registry = createEmptyPluginRegistry();
setActivePluginRegistry(registry);
const targets = new Map<string, Array<{ path: string; id: string }>>();
const registeredA = registerWebhookTargetWithPluginRoute({
targetsByPath: targets,
target: { path: "/hook", id: "A" },
route: {
auth: "plugin",
pluginId: "demo",
source: "demo-webhook",
handler: () => {},
},
});
const registeredB = registerWebhookTargetWithPluginRoute({
targetsByPath: targets,
target: { path: "/hook", id: "B" },
route: {
auth: "plugin",
pluginId: "demo",
source: "demo-webhook",
handler: () => {},
},
});
expect(registry.httpRoutes).toHaveLength(1);
expect(registry.httpRoutes[0]).toEqual(
expect.objectContaining({
pluginId: "demo",
path: "/hook",
source: "demo-webhook",
}),
);
registeredA.unregister();
expect(registry.httpRoutes).toHaveLength(1);
registeredB.unregister();
expect(registry.httpRoutes).toHaveLength(0);
});
});
describe("resolveWebhookTargets", () => { describe("resolveWebhookTargets", () => {
it("resolves normalized path targets", () => { it("resolves normalized path targets", () => {
const targets = new Map<string, Array<{ id: string }>>(); const targets = new Map<string, Array<{ id: string }>>();

View File

@@ -1,4 +1,5 @@
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import { registerPluginHttpRoute } from "../plugins/http-registry.js";
import { normalizeWebhookPath } from "./webhook-path.js"; import { normalizeWebhookPath } from "./webhook-path.js";
export type RegisteredWebhookTarget<T> = { export type RegisteredWebhookTarget<T> = {
@@ -11,6 +12,30 @@ export type RegisterWebhookTargetOptions<T extends { path: string }> = {
onLastPathTargetRemoved?: (params: { path: string }) => void; onLastPathTargetRemoved?: (params: { path: string }) => void;
}; };
type RegisterPluginHttpRouteParams = Parameters<typeof registerPluginHttpRoute>[0];
export type RegisterWebhookPluginRouteOptions = Omit<
RegisterPluginHttpRouteParams,
"path" | "fallbackPath"
>;
export function registerWebhookTargetWithPluginRoute<T extends { path: string }>(params: {
targetsByPath: Map<string, T[]>;
target: T;
route: RegisterWebhookPluginRouteOptions;
onLastPathTargetRemoved?: RegisterWebhookTargetOptions<T>["onLastPathTargetRemoved"];
}): RegisteredWebhookTarget<T> {
return registerWebhookTarget(params.targetsByPath, params.target, {
onFirstPathTarget: ({ path }) =>
registerPluginHttpRoute({
...params.route,
path,
replaceExisting: params.route.replaceExisting ?? true,
}),
onLastPathTargetRemoved: params.onLastPathTargetRemoved,
});
}
const pathTeardownByTargetMap = new WeakMap<Map<string, unknown[]>, Map<string, () => void>>(); const pathTeardownByTargetMap = new WeakMap<Map<string, unknown[]>, Map<string, () => void>>();
function getPathTeardownMap<T>(targetsByPath: Map<string, T[]>): Map<string, () => void> { function getPathTeardownMap<T>(targetsByPath: Map<string, T[]>): Map<string, () => void> {

View File

@@ -9,6 +9,7 @@ describe("registerPluginHttpRoute", () => {
const unregister = registerPluginHttpRoute({ const unregister = registerPluginHttpRoute({
path: "/plugins/demo", path: "/plugins/demo",
auth: "plugin",
handler, handler,
registry, registry,
}); });
@@ -16,7 +17,7 @@ describe("registerPluginHttpRoute", () => {
expect(registry.httpRoutes).toHaveLength(1); expect(registry.httpRoutes).toHaveLength(1);
expect(registry.httpRoutes[0]?.path).toBe("/plugins/demo"); expect(registry.httpRoutes[0]?.path).toBe("/plugins/demo");
expect(registry.httpRoutes[0]?.handler).toBe(handler); expect(registry.httpRoutes[0]?.handler).toBe(handler);
expect(registry.httpRoutes[0]?.auth).toBe("gateway"); expect(registry.httpRoutes[0]?.auth).toBe("plugin");
expect(registry.httpRoutes[0]?.match).toBe("exact"); expect(registry.httpRoutes[0]?.match).toBe("exact");
unregister(); unregister();
@@ -28,6 +29,7 @@ describe("registerPluginHttpRoute", () => {
const logs: string[] = []; const logs: string[] = [];
const unregister = registerPluginHttpRoute({ const unregister = registerPluginHttpRoute({
path: "", path: "",
auth: "plugin",
handler: vi.fn(), handler: vi.fn(),
registry, registry,
accountId: "default", accountId: "default",
@@ -39,7 +41,7 @@ describe("registerPluginHttpRoute", () => {
expect(() => unregister()).not.toThrow(); expect(() => unregister()).not.toThrow();
}); });
it("replaces stale route on same path and keeps latest registration", () => { it("replaces stale route on same path when replaceExisting=true", () => {
const registry = createEmptyPluginRegistry(); const registry = createEmptyPluginRegistry();
const logs: string[] = []; const logs: string[] = [];
const firstHandler = vi.fn(); const firstHandler = vi.fn();
@@ -47,6 +49,7 @@ describe("registerPluginHttpRoute", () => {
const unregisterFirst = registerPluginHttpRoute({ const unregisterFirst = registerPluginHttpRoute({
path: "/plugins/synology", path: "/plugins/synology",
auth: "plugin",
handler: firstHandler, handler: firstHandler,
registry, registry,
accountId: "default", accountId: "default",
@@ -56,6 +59,8 @@ describe("registerPluginHttpRoute", () => {
const unregisterSecond = registerPluginHttpRoute({ const unregisterSecond = registerPluginHttpRoute({
path: "/plugins/synology", path: "/plugins/synology",
auth: "plugin",
replaceExisting: true,
handler: secondHandler, handler: secondHandler,
registry, registry,
accountId: "default", accountId: "default",
@@ -77,4 +82,67 @@ describe("registerPluginHttpRoute", () => {
unregisterSecond(); unregisterSecond();
expect(registry.httpRoutes).toHaveLength(0); expect(registry.httpRoutes).toHaveLength(0);
}); });
it("rejects conflicting route registrations without replaceExisting", () => {
const registry = createEmptyPluginRegistry();
const logs: string[] = [];
registerPluginHttpRoute({
path: "/plugins/demo",
auth: "plugin",
handler: vi.fn(),
registry,
pluginId: "demo-a",
source: "demo-a-src",
log: (msg) => logs.push(msg),
});
const unregister = registerPluginHttpRoute({
path: "/plugins/demo",
auth: "plugin",
handler: vi.fn(),
registry,
pluginId: "demo-b",
source: "demo-b-src",
log: (msg) => logs.push(msg),
});
expect(registry.httpRoutes).toHaveLength(1);
expect(logs.at(-1)).toContain("route conflict");
unregister();
expect(registry.httpRoutes).toHaveLength(1);
});
it("rejects route replacement when a different plugin owns the route", () => {
const registry = createEmptyPluginRegistry();
const logs: string[] = [];
registerPluginHttpRoute({
path: "/plugins/demo",
auth: "plugin",
handler: vi.fn(),
registry,
pluginId: "demo-a",
source: "demo-a-src",
log: (msg) => logs.push(msg),
});
const unregister = registerPluginHttpRoute({
path: "/plugins/demo",
auth: "plugin",
replaceExisting: true,
handler: vi.fn(),
registry,
pluginId: "demo-b",
source: "demo-b-src",
log: (msg) => logs.push(msg),
});
expect(registry.httpRoutes).toHaveLength(1);
expect(logs.at(-1)).toContain("route replacement denied");
unregister();
expect(registry.httpRoutes).toHaveLength(1);
});
}); });

View File

@@ -12,8 +12,9 @@ export function registerPluginHttpRoute(params: {
path?: string | null; path?: string | null;
fallbackPath?: string | null; fallbackPath?: string | null;
handler: PluginHttpRouteHandler; handler: PluginHttpRouteHandler;
auth?: PluginHttpRouteRegistration["auth"]; auth: PluginHttpRouteRegistration["auth"];
match?: PluginHttpRouteRegistration["match"]; match?: PluginHttpRouteRegistration["match"];
replaceExisting?: boolean;
pluginId?: string; pluginId?: string;
source?: string; source?: string;
accountId?: string; accountId?: string;
@@ -36,6 +37,22 @@ export function registerPluginHttpRoute(params: {
(entry) => entry.path === normalizedPath && entry.match === routeMatch, (entry) => entry.path === normalizedPath && entry.match === routeMatch,
); );
if (existingIndex >= 0) { if (existingIndex >= 0) {
const existing = routes[existingIndex];
if (!existing) {
return () => {};
}
if (!params.replaceExisting) {
params.log?.(
`plugin: route conflict at ${normalizedPath} (${routeMatch})${suffix}; owned by ${existing.pluginId ?? "unknown-plugin"} (${existing.source ?? "unknown-source"})`,
);
return () => {};
}
if (existing.pluginId && params.pluginId && existing.pluginId !== params.pluginId) {
params.log?.(
`plugin: route replacement denied for ${normalizedPath} (${routeMatch})${suffix}; owned by ${existing.pluginId}`,
);
return () => {};
}
const pluginHint = params.pluginId ? ` (${params.pluginId})` : ""; const pluginHint = params.pluginId ? ` (${params.pluginId})` : "";
params.log?.( params.log?.(
`plugin: replacing stale webhook path ${normalizedPath} (${routeMatch})${suffix}${pluginHint}`, `plugin: replacing stale webhook path ${normalizedPath} (${routeMatch})${suffix}${pluginHint}`,
@@ -46,7 +63,7 @@ export function registerPluginHttpRoute(params: {
const entry: PluginHttpRouteRegistration = { const entry: PluginHttpRouteRegistration = {
path: normalizedPath, path: normalizedPath,
handler: params.handler, handler: params.handler,
auth: params.auth ?? "gateway", auth: params.auth,
match: routeMatch, match: routeMatch,
pluginId: params.pluginId, pluginId: params.pluginId,
source: params.source, source: params.source,

View File

@@ -548,7 +548,7 @@ describe("loadOpenClawPlugins", () => {
id: "http-route-demo", id: "http-route-demo",
filename: "http-route-demo.cjs", filename: "http-route-demo.cjs",
body: `module.exports = { id: "http-route-demo", register(api) { body: `module.exports = { id: "http-route-demo", register(api) {
api.registerHttpRoute({ path: "/demo", handler: async (_req, res) => { res.statusCode = 200; res.end("ok"); } }); api.registerHttpRoute({ path: "/demo", auth: "gateway", handler: async (_req, res) => { res.statusCode = 200; res.end("ok"); } });
} };`, } };`,
}); });
@@ -568,6 +568,95 @@ describe("loadOpenClawPlugins", () => {
expect(httpPlugin?.httpRoutes).toBe(1); expect(httpPlugin?.httpRoutes).toBe(1);
}); });
it("rejects plugin http routes missing explicit auth", () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "http-route-missing-auth",
filename: "http-route-missing-auth.cjs",
body: `module.exports = { id: "http-route-missing-auth", register(api) {
api.registerHttpRoute({ path: "/demo", handler: async () => true });
} };`,
});
const registry = loadRegistryFromSinglePlugin({
plugin,
pluginConfig: {
allow: ["http-route-missing-auth"],
},
});
expect(registry.httpRoutes.find((entry) => entry.pluginId === "http-route-missing-auth")).toBe(
undefined,
);
expect(
registry.diagnostics.some((diag) =>
String(diag.message).includes("http route registration missing or invalid auth"),
),
).toBe(true);
});
it("allows explicit replaceExisting for same-plugin http route overrides", () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "http-route-replace-self",
filename: "http-route-replace-self.cjs",
body: `module.exports = { id: "http-route-replace-self", register(api) {
api.registerHttpRoute({ path: "/demo", auth: "plugin", handler: async () => false });
api.registerHttpRoute({ path: "/demo", auth: "plugin", replaceExisting: true, handler: async () => true });
} };`,
});
const registry = loadRegistryFromSinglePlugin({
plugin,
pluginConfig: {
allow: ["http-route-replace-self"],
},
});
const routes = registry.httpRoutes.filter(
(entry) => entry.pluginId === "http-route-replace-self",
);
expect(routes).toHaveLength(1);
expect(routes[0]?.path).toBe("/demo");
expect(registry.diagnostics).toEqual([]);
});
it("rejects http route replacement when another plugin owns the route", () => {
useNoBundledPlugins();
const first = writePlugin({
id: "http-route-owner-a",
filename: "http-route-owner-a.cjs",
body: `module.exports = { id: "http-route-owner-a", register(api) {
api.registerHttpRoute({ path: "/demo", auth: "plugin", handler: async () => false });
} };`,
});
const second = writePlugin({
id: "http-route-owner-b",
filename: "http-route-owner-b.cjs",
body: `module.exports = { id: "http-route-owner-b", register(api) {
api.registerHttpRoute({ path: "/demo", auth: "plugin", replaceExisting: true, handler: async () => true });
} };`,
});
const registry = loadOpenClawPlugins({
cache: false,
config: {
plugins: {
load: { paths: [first.file, second.file] },
allow: ["http-route-owner-a", "http-route-owner-b"],
},
},
});
const route = registry.httpRoutes.find((entry) => entry.path === "/demo");
expect(route?.pluginId).toBe("http-route-owner-a");
expect(
registry.diagnostics.some((diag) =>
String(diag.message).includes("http route replacement rejected"),
),
).toBe(true);
});
it("respects explicit disable in config", () => { it("respects explicit disable in config", () => {
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const plugin = writePlugin({ const plugin = writePlugin({

View File

@@ -284,6 +284,12 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
record.gatewayMethods.push(trimmed); record.gatewayMethods.push(trimmed);
}; };
const describeHttpRouteOwner = (entry: PluginHttpRouteRegistration): string => {
const plugin = entry.pluginId?.trim() || "unknown-plugin";
const source = entry.source?.trim() || "unknown-source";
return `${plugin} (${source})`;
};
const registerHttpRoute = (record: PluginRecord, params: OpenClawPluginHttpRouteParams) => { const registerHttpRoute = (record: PluginRecord, params: OpenClawPluginHttpRouteParams) => {
const normalizedPath = normalizePluginHttpPath(params.path); const normalizedPath = normalizePluginHttpPath(params.path);
if (!normalizedPath) { if (!normalizedPath) {
@@ -295,24 +301,58 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
}); });
return; return;
} }
const match = params.match ?? "exact"; if (params.auth !== "gateway" && params.auth !== "plugin") {
if (
registry.httpRoutes.some((entry) => entry.path === normalizedPath && entry.match === match)
) {
pushDiagnostic({ pushDiagnostic({
level: "error", level: "error",
pluginId: record.id, pluginId: record.id,
source: record.source, source: record.source,
message: `http route already registered: ${normalizedPath} (${match})`, message: `http route registration missing or invalid auth: ${normalizedPath}`,
}); });
return; return;
} }
const match = params.match ?? "exact";
const existingIndex = registry.httpRoutes.findIndex(
(entry) => entry.path === normalizedPath && entry.match === match,
);
if (existingIndex >= 0) {
const existing = registry.httpRoutes[existingIndex];
if (!existing) {
return;
}
if (!params.replaceExisting) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `http route already registered: ${normalizedPath} (${match}) by ${describeHttpRouteOwner(existing)}`,
});
return;
}
if (existing.pluginId && existing.pluginId !== record.id) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `http route replacement rejected: ${normalizedPath} (${match}) owned by ${describeHttpRouteOwner(existing)}`,
});
return;
}
registry.httpRoutes[existingIndex] = {
pluginId: record.id,
path: normalizedPath,
handler: params.handler,
auth: params.auth,
match,
source: record.source,
};
return;
}
record.httpRoutes += 1; record.httpRoutes += 1;
registry.httpRoutes.push({ registry.httpRoutes.push({
pluginId: record.id, pluginId: record.id,
path: normalizedPath, path: normalizedPath,
handler: params.handler, handler: params.handler,
auth: params.auth ?? "gateway", auth: params.auth,
match, match,
source: record.source, source: record.source,
}); });

View File

@@ -205,8 +205,9 @@ export type OpenClawPluginHttpRouteHandler = (
export type OpenClawPluginHttpRouteParams = { export type OpenClawPluginHttpRouteParams = {
path: string; path: string;
handler: OpenClawPluginHttpRouteHandler; handler: OpenClawPluginHttpRouteHandler;
auth?: OpenClawPluginHttpRouteAuth; auth: OpenClawPluginHttpRouteAuth;
match?: OpenClawPluginHttpRouteMatch; match?: OpenClawPluginHttpRouteMatch;
replaceExisting?: boolean;
}; };
export type OpenClawPluginCliContext = { export type OpenClawPluginCliContext = {