fix(nostr): require operator.admin scope for profile mutation routes [AI] (#63553)

* fix: address issue

* fix: address review feedback

* fix: address review feedback

* fix: finalize issue changes

* fix: address PR review feedback

* fix: address review-pr skill feedback

* fix: address PR review feedback

* fix: address review-pr skill feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* docs: add changelog entry for PR merge
This commit is contained in:
Pavan Kumar Gondhi
2026-04-10 16:38:41 +05:30
committed by GitHub
parent 0e54440ecc
commit 6517c700de
15 changed files with 462 additions and 41 deletions

View File

@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- fix(nostr): require operator.admin scope for profile mutation routes [AI]. (#63553) Thanks @pgondhi987.
- Gateway/startup: keep WebSocket RPC available while channels and plugin sidecars start, hold `chat.history` unavailable until startup sidecars finish so synchronous history reads cannot stall startup (reported in #63450), refresh advertised gateway methods after deferred plugin reloads, and enforce the pre-auth WebSocket upgrade budget before the no-handler 503 path so upgrade floods cannot bypass connection limits during that window. (#63480) Thanks @neeravmakwana.
- Gateway/tailscale: start Tailscale exposure and the gateway update check before awaiting channel and plugin sidecar startup so remote operators are not locked out when startup sidecars stall.
- WhatsApp: keep inbound replies, media, composing indicators, and queued outbound deliveries attached to the current socket across reconnect gaps, including fresh retry-eligible sends after the listener comes back. (#30806, #46299, #62892, #63916) Thanks @mcaxtr.

View File

@@ -87,6 +87,7 @@ export default defineBundledChannelEntry({
path: "/api/channels/nostr",
auth: "gateway",
match: "prefix",
gatewayRuntimeScopeSurface: "trusted-operator",
handler: httpHandler,
});
},

View File

@@ -55,7 +55,16 @@ export const NostrProfileSchema = z.object({
lud16: z.string().optional(),
});
export type NostrProfile = z.infer<typeof NostrProfileSchema>;
export interface NostrProfile {
name?: string;
displayName?: string;
about?: string;
picture?: string;
banner?: string;
website?: string;
nip05?: string;
lud16?: string;
}
/**
* Zod schema for channels.nostr.* configuration

View File

@@ -4,7 +4,8 @@
import { IncomingMessage, ServerResponse } from "node:http";
import { Socket } from "node:net";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import * as runtimeApi from "../runtime-api.js";
import {
clearNostrProfileRateLimitStateForTest,
createNostrProfileHttpHandler,
@@ -34,6 +35,25 @@ import { TEST_HEX_PUBLIC_KEY, TEST_SETUP_RELAY_URLS } from "./test-fixtures.js";
// ============================================================================
const TEST_PROFILE_RELAY_URL = TEST_SETUP_RELAY_URLS[0];
const runtimeScopeSpy = vi.spyOn(runtimeApi, "getPluginRuntimeGatewayRequestScope");
afterAll(() => {
runtimeScopeSpy.mockRestore();
});
function setGatewayRuntimeScopes(scopes: readonly string[] | undefined): void {
if (!scopes) {
runtimeScopeSpy.mockReturnValue(undefined);
return;
}
runtimeScopeSpy.mockReturnValue({
client: {
connect: {
scopes: [...scopes],
},
},
} as unknown as ReturnType<typeof runtimeApi.getPluginRuntimeGatewayRequestScope>);
}
function createMockRequest(
method: string,
@@ -94,10 +114,10 @@ function createMockResponse(): ServerResponse & {
},
});
(res as unknown as { _getData: () => string })._getData = () => data;
(res as unknown as { _getStatusCode: () => number })._getStatusCode = () => statusCode;
res._getData = () => data;
res._getStatusCode = () => statusCode;
return res as ServerResponse & { _getData: () => string; _getStatusCode: () => number };
return res;
}
type MockResponse = ReturnType<typeof createMockResponse>;
@@ -173,6 +193,7 @@ describe("nostr-profile-http", () => {
beforeEach(() => {
vi.clearAllMocks();
clearNostrProfileRateLimitStateForTest();
setGatewayRuntimeScopes(["operator.admin"]);
});
describe("route matching", () => {
@@ -323,6 +344,44 @@ describe("nostr-profile-http", () => {
expect(res._getStatusCode()).toBe(403);
});
it("rejects profile mutation when gateway caller is missing operator.admin", async () => {
setGatewayRuntimeScopes(["operator.read"]);
const { ctx, res, run } = createProfileHttpHarness(
"PUT",
"/api/channels/nostr/default/profile",
{
body: { name: "attacker" },
},
);
await run();
expect(res._getStatusCode()).toBe(403);
const data = JSON.parse(res._getData());
expect(data.error).toBe("missing scope: operator.admin");
expect(publishNostrProfile).not.toHaveBeenCalled();
expect(ctx.updateConfigProfile).not.toHaveBeenCalled();
});
it("rejects profile mutation when gateway scope context is missing", async () => {
setGatewayRuntimeScopes(undefined);
const { ctx, res, run } = createProfileHttpHarness(
"PUT",
"/api/channels/nostr/default/profile",
{
body: { name: "attacker" },
},
);
await run();
expect(res._getStatusCode()).toBe(403);
const data = JSON.parse(res._getData());
expect(data.error).toBe("missing scope: operator.admin");
expect(publishNostrProfile).not.toHaveBeenCalled();
expect(ctx.updateConfigProfile).not.toHaveBeenCalled();
});
it("rejects private IP in picture URL (SSRF protection)", async () => {
await expectPrivatePictureRejected("https://127.0.0.1/evil.jpg");
});
@@ -484,6 +543,44 @@ describe("nostr-profile-http", () => {
expect(res._getStatusCode()).toBe(403);
});
it("rejects profile import when gateway caller is missing operator.admin", async () => {
setGatewayRuntimeScopes(["operator.read"]);
const { ctx, res, run } = createProfileHttpHarness(
"POST",
"/api/channels/nostr/default/profile/import",
{
body: { autoMerge: true },
},
);
await run();
expect(res._getStatusCode()).toBe(403);
const data = JSON.parse(res._getData());
expect(data.error).toBe("missing scope: operator.admin");
expect(importProfileFromRelays).not.toHaveBeenCalled();
expect(ctx.updateConfigProfile).not.toHaveBeenCalled();
});
it("rejects profile import when gateway scope context is missing", async () => {
setGatewayRuntimeScopes(undefined);
const { ctx, res, run } = createProfileHttpHarness(
"POST",
"/api/channels/nostr/default/profile/import",
{
body: { autoMerge: true },
},
);
await run();
expect(res._getStatusCode()).toBe(403);
const data = JSON.parse(res._getData());
expect(data.error).toBe("missing scope: operator.admin");
expect(importProfileFromRelays).not.toHaveBeenCalled();
expect(ctx.updateConfigProfile).not.toHaveBeenCalled();
});
it("auto-merges when requested", async () => {
const { ctx, res, run } = createProfileHttpHarness(
"POST",

View File

@@ -16,6 +16,7 @@ import {
import { z } from "openclaw/plugin-sdk/zod";
import {
createFixedWindowRateLimiter,
getPluginRuntimeGatewayRequestScope,
readJsonBodyWithLimit,
requestBodyErrorToText,
} from "../runtime-api.js";
@@ -128,6 +129,8 @@ const ProfileUpdateSchema = NostrProfileSchema.extend({
lud16: lud16FormatSchema,
});
const PROFILE_MUTATION_SCOPE = "operator.admin";
// ============================================================================
// Request Helpers
// ============================================================================
@@ -298,6 +301,21 @@ function enforceLoopbackMutationGuards(
return true;
}
function enforceGatewayMutationScope(
ctx: NostrProfileHttpContext,
accountId: string,
res: ServerResponse,
): boolean {
const runtimeScopes = getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes;
const scopes = Array.isArray(runtimeScopes) ? runtimeScopes : [];
if (scopes.includes(PROFILE_MUTATION_SCOPE)) {
return true;
}
ctx.log?.warn?.(`[${accountId}] Rejected profile mutation missing ${PROFILE_MUTATION_SCOPE}`);
sendJson(res, 403, { ok: false, error: `missing scope: ${PROFILE_MUTATION_SCOPE}` });
return false;
}
// ============================================================================
// HTTP Handler
// ============================================================================
@@ -380,6 +398,9 @@ async function handleUpdateProfile(
req: IncomingMessage,
res: ServerResponse,
): Promise<true> {
if (!enforceGatewayMutationScope(ctx, accountId, res)) {
return true;
}
if (!enforceLoopbackMutationGuards(ctx, req, res)) {
return true;
}
@@ -483,6 +504,9 @@ async function handleImportProfile(
req: IncomingMessage,
res: ServerResponse,
): Promise<true> {
if (!enforceGatewayMutationScope(ctx, accountId, res)) {
return true;
}
if (!enforceLoopbackMutationGuards(ctx, req, res)) {
return true;
}

View File

@@ -59,6 +59,7 @@ import {
} from "./hooks.js";
import { sendGatewayAuthFailure, setDefaultSecurityHeaders } from "./http-common.js";
import {
type AuthorizedGatewayHttpRequest,
authorizeGatewayHttpRequestOrReply,
getBearerToken,
resolveHttpBrowserOriginPolicy,
@@ -322,6 +323,7 @@ function buildPluginRequestStages(params: {
return [];
}
let pluginGatewayAuthSatisfied = false;
let pluginGatewayRequestAuth: AuthorizedGatewayHttpRequest | undefined;
let pluginRequestOperatorScopes: string[] | undefined;
return [
{
@@ -351,6 +353,7 @@ function buildPluginRequestStages(params: {
return true;
}
pluginGatewayAuthSatisfied = true;
pluginGatewayRequestAuth = requestAuth;
pluginRequestOperatorScopes = resolvePluginRouteRuntimeOperatorScopes(
params.req,
requestAuth,
@@ -366,6 +369,7 @@ function buildPluginRequestStages(params: {
return (
params.handlePluginRequest?.(params.req, params.res, pathContext, {
gatewayAuthSatisfied: pluginGatewayAuthSatisfied,
gatewayRequestAuth: pluginGatewayRequestAuth,
gatewayRequestOperatorScopes: pluginRequestOperatorScopes,
}) ?? false
);

View File

@@ -382,6 +382,68 @@ describe("gateway plugin HTTP auth boundary", () => {
expect(writeAllowedResults).toEqual([true]);
});
test("allows trusted-operator plugin routes to resolve admin-capable runtime scopes for shared-secret bearer auth without scope headers", async () => {
const observedRuntimeScopes: string[][] = [];
const adminAllowedResults: boolean[] = [];
const handlePluginRequest = createGatewayPluginRequestHandler({
registry: createTestRegistry({
httpRoutes: [
{
pluginId: "runtime-scope-bearer-trusted-operator",
source: "runtime-scope-bearer-trusted-operator",
path: "/secure-admin-hook",
auth: "gateway",
gatewayRuntimeScopeSurface: "trusted-operator",
match: "exact",
handler: async (_req: IncomingMessage, res: ServerResponse) => {
const runtimeScopes =
getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes?.slice() ?? [];
observedRuntimeScopes.push(runtimeScopes);
const adminAuth = authorizeOperatorScopesForMethod("set-heartbeats", runtimeScopes);
adminAllowedResults.push(adminAuth.allowed);
res.statusCode = 200;
res.end("ok");
return true;
},
},
],
}),
log: { warn: vi.fn() } as unknown as Parameters<
typeof createGatewayPluginRequestHandler
>[0]["log"],
});
await withGatewayServer({
prefix: "openclaw-plugin-http-runtime-scope-bearer-trusted-operator-test-",
resolvedAuth: AUTH_TOKEN,
overrides: {
handlePluginRequest,
shouldEnforcePluginGatewayAuth: (pathContext) =>
pathContext.pathname === "/secure-admin-hook",
},
run: async (server) => {
const response = createResponse();
await dispatchRequest(
server,
createRequest({
path: "/secure-admin-hook",
authorization: "Bearer test-token",
}),
response.res,
);
expect(response.res.statusCode).toBe(200);
expect(response.getBody()).toBe("ok");
},
});
expect(observedRuntimeScopes).toHaveLength(1);
expect(observedRuntimeScopes[0]).toEqual(
expect.arrayContaining(["operator.admin", "operator.read", "operator.write"]),
);
expect(adminAllowedResults).toEqual([true]);
});
test("allows unauthenticated Mattermost slash callback routes while keeping other channel routes protected", async () => {
const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;

View File

@@ -45,4 +45,50 @@ describe("resolvePluginRouteRuntimeOperatorScopes", () => {
),
).toEqual(["operator.write"]);
});
it("restores trusted default operator scopes for shared-secret bearer routes opting into trusted-operator surface", () => {
expect(
resolvePluginRouteRuntimeOperatorScopes(
createReq({
authorization: "Bearer secret",
}),
{ authMethod: "token", trustDeclaredOperatorScopes: false },
"trusted-operator",
),
).toEqual([
"operator.admin",
"operator.read",
"operator.write",
"operator.approvals",
"operator.pairing",
"operator.talk.secrets",
]);
});
it("restores trusted default operator scopes for trusted-proxy routes opting into trusted-operator when scopes header is absent", () => {
expect(
resolvePluginRouteRuntimeOperatorScopes(
createReq(),
{ authMethod: "trusted-proxy", trustDeclaredOperatorScopes: true },
"trusted-operator",
),
).toEqual([
"operator.admin",
"operator.read",
"operator.write",
"operator.approvals",
"operator.pairing",
"operator.talk.secrets",
]);
});
it("preserves trusted-proxy declared scopes for routes opting into trusted-operator surface", () => {
expect(
resolvePluginRouteRuntimeOperatorScopes(
createReq({ "x-openclaw-scopes": "operator.admin,operator.write" }),
{ authMethod: "trusted-proxy", trustDeclaredOperatorScopes: true },
"trusted-operator",
),
).toEqual(["operator.admin", "operator.write"]);
});
});

View File

@@ -4,12 +4,21 @@ import {
resolveTrustedHttpOperatorScopes,
type AuthorizedGatewayHttpRequest,
} from "../http-utils.js";
import { WRITE_SCOPE } from "../method-scopes.js";
import { CLI_DEFAULT_OPERATOR_SCOPES, WRITE_SCOPE } from "../method-scopes.js";
export type PluginRouteRuntimeScopeSurface = "write-default" | "trusted-operator";
export function resolvePluginRouteRuntimeOperatorScopes(
req: IncomingMessage,
requestAuth: AuthorizedGatewayHttpRequest,
surface: PluginRouteRuntimeScopeSurface = "write-default",
): string[] {
if (surface === "trusted-operator") {
if (!requestAuth.trustDeclaredOperatorScopes) {
return [...CLI_DEFAULT_OPERATOR_SCOPES];
}
return resolveTrustedHttpOperatorScopes(req, requestAuth);
}
if (requestAuth.authMethod !== "trusted-proxy") {
return [WRITE_SCOPE];
}

View File

@@ -7,6 +7,7 @@ import {
setActivePluginRegistry,
} from "../../plugins/runtime.js";
import { getPluginRuntimeGatewayRequestScope } from "../../plugins/runtime/gateway-request-scope.js";
import type { AuthorizedGatewayHttpRequest } from "../http-utils.js";
import { authorizeOperatorScopesForMethod } from "../method-scopes.js";
import { makeMockHttpResponse } from "../test-http-response.js";
import { createTestRegistry } from "./__tests__/test-utils.js";
@@ -15,13 +16,16 @@ import { createGatewayPluginRequestHandler } from "./plugins-http.js";
function createRoute(params: {
path: string;
auth: "gateway" | "plugin";
match?: "exact" | "prefix";
gatewayRuntimeScopeSurface?: "write-default" | "trusted-operator";
handler?: (req: IncomingMessage, res: ServerResponse) => boolean | Promise<boolean>;
}) {
return {
pluginId: "route",
path: params.path,
auth: params.auth,
match: "exact" as const,
gatewayRuntimeScopeSurface: params.gatewayRuntimeScopeSurface,
match: params.match ?? "exact",
handler: params.handler ?? (() => true),
source: "route",
};
@@ -53,6 +57,14 @@ function assertWriteHelperAllowed() {
}
}
function assertAdminHelperAllowed() {
const scopes = getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes ?? [];
const auth = authorizeOperatorScopesForMethod("set-heartbeats", scopes);
if (!auth.allowed) {
throw new Error(`missing scope: ${auth.missingScope}`);
}
}
describe("plugin HTTP route runtime scopes", () => {
afterEach(() => {
releasePinnedPluginHttpRouteRegistry();
@@ -62,7 +74,9 @@ describe("plugin HTTP route runtime scopes", () => {
async function invokeRoute(params: {
path: string;
auth: "gateway" | "plugin";
gatewayRuntimeScopeSurface?: "write-default" | "trusted-operator";
gatewayAuthSatisfied: boolean;
gatewayRequestAuth?: AuthorizedGatewayHttpRequest;
gatewayRequestOperatorScopes?: readonly string[];
}) {
const log = createMockLogger();
@@ -72,6 +86,7 @@ describe("plugin HTTP route runtime scopes", () => {
createRoute({
path: params.path,
auth: params.auth,
gatewayRuntimeScopeSurface: params.gatewayRuntimeScopeSurface,
handler: async () => {
assertWriteHelperAllowed();
return true;
@@ -89,6 +104,7 @@ describe("plugin HTTP route runtime scopes", () => {
undefined,
{
gatewayAuthSatisfied: params.gatewayAuthSatisfied,
gatewayRequestAuth: params.gatewayRequestAuth,
gatewayRequestOperatorScopes: params.gatewayRequestOperatorScopes,
},
);
@@ -151,6 +167,113 @@ describe("plugin HTTP route runtime scopes", () => {
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("missing scope: operator.write"));
});
it("restores trusted-operator defaults for routes opting into trusted surface", async () => {
let observedScopes: string[] | undefined;
const log = createMockLogger();
const handler = createGatewayPluginRequestHandler({
registry: createTestRegistry({
httpRoutes: [
createRoute({
path: "/secure-admin-hook",
auth: "gateway",
gatewayRuntimeScopeSurface: "trusted-operator",
handler: async () => {
observedScopes =
getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes?.slice() ?? [];
assertAdminHelperAllowed();
return true;
},
}),
],
}),
log,
});
const response = makeMockHttpResponse();
const handled = await handler(
{ url: "/secure-admin-hook" } as IncomingMessage,
response.res,
undefined,
{
gatewayAuthSatisfied: true,
gatewayRequestAuth: { authMethod: "token", trustDeclaredOperatorScopes: false },
gatewayRequestOperatorScopes: ["operator.write"],
},
);
expect(handled).toBe(true);
expect(response.res.statusCode).toBe(200);
expect(log.warn).not.toHaveBeenCalled();
expect(observedScopes).toEqual(
expect.arrayContaining(["operator.admin", "operator.read", "operator.write"]),
);
});
it("scopes runtime privileges per matched route for exact/prefix overlap", async () => {
const observed: Array<{ route: "exact" | "prefix"; scopes: string[] }> = [];
const log = createMockLogger();
const handler = createGatewayPluginRequestHandler({
registry: createTestRegistry({
httpRoutes: [
createRoute({
path: "/secure/admin-hook",
auth: "gateway",
match: "exact",
handler: async () => {
observed.push({
route: "exact",
scopes:
getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes?.slice() ?? [],
});
return false;
},
}),
createRoute({
path: "/secure",
auth: "gateway",
match: "prefix",
gatewayRuntimeScopeSurface: "trusted-operator",
handler: async () => {
observed.push({
route: "prefix",
scopes:
getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes?.slice() ?? [],
});
assertAdminHelperAllowed();
return true;
},
}),
],
}),
log,
});
const response = makeMockHttpResponse();
const handled = await handler(
{ url: "/secure/admin-hook" } as IncomingMessage,
response.res,
undefined,
{
gatewayAuthSatisfied: true,
gatewayRequestAuth: { authMethod: "token", trustDeclaredOperatorScopes: false },
gatewayRequestOperatorScopes: ["operator.write"],
},
);
expect(handled).toBe(true);
expect(response.res.statusCode).toBe(200);
expect(log.warn).not.toHaveBeenCalled();
expect(observed).toHaveLength(2);
expect(observed[0]).toEqual({
route: "exact",
scopes: ["operator.write"],
});
expect(observed[1]?.route).toBe("prefix");
expect(observed[1]?.scopes).toEqual(
expect.arrayContaining(["operator.admin", "operator.read", "operator.write"]),
);
});
it.each([
{
auth: "plugin" as const,

View File

@@ -3,9 +3,11 @@ import type { createSubsystemLogger } from "../../logging/subsystem.js";
import type { PluginRegistry } from "../../plugins/registry.js";
import { resolveActivePluginHttpRouteRegistry } from "../../plugins/runtime.js";
import { withPluginRuntimeGatewayRequestScope } from "../../plugins/runtime/gateway-request-scope.js";
import type { AuthorizedGatewayHttpRequest } from "../http-utils.js";
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../protocol/client-info.js";
import { PROTOCOL_VERSION } from "../protocol/index.js";
import type { GatewayRequestOptions } from "../server-methods/types.js";
import { resolvePluginRouteRuntimeOperatorScopes } from "./plugin-route-runtime-scopes.js";
import {
resolvePluginRoutePathContext,
type PluginRoutePathContext,
@@ -47,6 +49,7 @@ function createPluginRouteRuntimeClient(
export type PluginRouteDispatchContext = {
gatewayAuthSatisfied?: boolean;
gatewayRequestAuth?: AuthorizedGatewayHttpRequest;
gatewayRequestOperatorScopes?: readonly string[];
};
@@ -80,46 +83,72 @@ export function createGatewayPluginRequestHandler(params: {
return false;
}
const requiresGatewayAuth = matchedPluginRoutesRequireGatewayAuth(matchedRoutes);
let runtimeScopes: readonly string[] = [];
if (requiresGatewayAuth) {
if (dispatchContext?.gatewayAuthSatisfied !== true) {
log.warn(`plugin http route blocked without gateway auth (${pathContext.canonicalPath})`);
return false;
if (requiresGatewayAuth && dispatchContext?.gatewayAuthSatisfied !== true) {
log.warn(`plugin http route blocked without gateway auth (${pathContext.canonicalPath})`);
return false;
}
const gatewayRequestAuth = dispatchContext?.gatewayRequestAuth;
const gatewayRequestOperatorScopes = dispatchContext?.gatewayRequestOperatorScopes;
// Fail closed before invoking any handlers when matched gateway routes are
// missing the runtime auth/scope context they require.
for (const route of matchedRoutes) {
if (route.auth !== "gateway") {
continue;
}
if (dispatchContext.gatewayRequestOperatorScopes === undefined) {
if (route.gatewayRuntimeScopeSurface === "trusted-operator") {
if (!gatewayRequestAuth) {
log.warn(
`plugin http route blocked without caller auth context (${pathContext.canonicalPath})`,
);
return false;
}
continue;
}
if (gatewayRequestOperatorScopes === undefined) {
log.warn(
`plugin http route blocked without caller scope context (${pathContext.canonicalPath})`,
);
return false;
}
runtimeScopes = dispatchContext.gatewayRequestOperatorScopes;
}
const runtimeClient = createPluginRouteRuntimeClient(runtimeScopes);
return await withPluginRuntimeGatewayRequestScope(
{
client: runtimeClient,
isWebchatConnect: () => false,
},
async () => {
for (const route of matchedRoutes) {
try {
const handled = await route.handler(req, res);
if (handled !== false) {
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;
}
for (const route of matchedRoutes) {
let runtimeScopes: readonly string[] = [];
if (route.auth === "gateway") {
if (route.gatewayRuntimeScopeSurface === "trusted-operator") {
runtimeScopes = resolvePluginRouteRuntimeOperatorScopes(
req,
gatewayRequestAuth!,
"trusted-operator",
);
} else {
runtimeScopes = gatewayRequestOperatorScopes!;
}
return false;
},
);
}
const runtimeClient = createPluginRouteRuntimeClient(runtimeScopes);
try {
const handled = await withPluginRuntimeGatewayRequestScope(
{
client: runtimeClient,
isWebchatConnect: () => false,
},
async () => route.handler(req, res),
);
if (handled !== false) {
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;
}
}
return false;
};
}

View File

@@ -7,6 +7,7 @@ export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js";
export type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js";
export { formatPairingApproveHint } from "../channels/plugins/helpers.js";
export type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
export { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js";
export { createChannelReplyPipeline } from "./channel-reply-pipeline.js";
export {
createDirectDmPreCryptoGuardPolicy,

View File

@@ -15,6 +15,7 @@ export function registerPluginHttpRoute(params: {
handler: PluginHttpRouteHandler;
auth: PluginHttpRouteRegistration["auth"];
match?: PluginHttpRouteRegistration["match"];
gatewayRuntimeScopeSurface?: PluginHttpRouteRegistration["gatewayRuntimeScopeSurface"];
replaceExisting?: boolean;
pluginId?: string;
source?: string;
@@ -78,6 +79,9 @@ export function registerPluginHttpRoute(params: {
handler: params.handler,
auth: params.auth,
match: routeMatch,
...(params.gatewayRuntimeScopeSurface
? { gatewayRuntimeScopeSurface: params.gatewayRuntimeScopeSurface }
: {}),
pluginId: params.pluginId,
source: params.source,
};

View File

@@ -46,7 +46,7 @@ import type {
PluginCommandRegistration,
PluginConversationBindingResolvedHandlerRegistration,
PluginHookRegistration,
PluginHttpRouteRegistration,
PluginHttpRouteRegistration as RegistryTypesPluginHttpRouteRegistration,
PluginMemoryEmbeddingProviderRegistration,
PluginNodeHostCommandRegistration,
PluginProviderRegistration,
@@ -75,6 +75,7 @@ import type {
OpenClawPluginCliRegistrar,
OpenClawPluginCommandDefinition,
PluginConversationBindingResolvedEvent,
OpenClawPluginGatewayRuntimeScopeSurface,
OpenClawPluginHttpRouteParams,
OpenClawPluginHookOptions,
OpenClawPluginNodeHostCommand,
@@ -99,6 +100,9 @@ import type {
WebSearchProviderPlugin,
} from "./types.js";
export type PluginHttpRouteRegistration = RegistryTypesPluginHttpRouteRegistration & {
gatewayRuntimeScopeSurface?: OpenClawPluginGatewayRuntimeScopeSurface;
};
type PluginOwnedProviderRegistration<T extends { id: string }> = {
pluginId: string;
pluginName?: string;
@@ -115,7 +119,6 @@ export type {
PluginCommandRegistration,
PluginConversationBindingResolvedHandlerRegistration,
PluginHookRegistration,
PluginHttpRouteRegistration,
PluginMemoryEmbeddingProviderRegistration,
PluginNodeHostCommandRegistration,
PluginProviderRegistration,
@@ -390,6 +393,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
handler: params.handler,
auth: params.auth,
match,
...(params.gatewayRuntimeScopeSurface
? { gatewayRuntimeScopeSurface: params.gatewayRuntimeScopeSurface }
: {}),
source: record.source,
};
return;
@@ -401,6 +407,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
handler: params.handler,
auth: params.auth,
match,
...(params.gatewayRuntimeScopeSurface
? { gatewayRuntimeScopeSurface: params.gatewayRuntimeScopeSurface }
: {}),
source: record.source,
});
};

View File

@@ -1960,6 +1960,7 @@ export type PluginInteractiveHandlerRegistration = PluginInteractiveRegistration
export type OpenClawPluginHttpRouteAuth = "gateway" | "plugin";
export type OpenClawPluginHttpRouteMatch = "exact" | "prefix";
export type OpenClawPluginGatewayRuntimeScopeSurface = "write-default" | "trusted-operator";
export type OpenClawPluginHttpRouteHandler = (
req: IncomingMessage,
@@ -1971,6 +1972,7 @@ export type OpenClawPluginHttpRouteParams = {
handler: OpenClawPluginHttpRouteHandler;
auth: OpenClawPluginHttpRouteAuth;
match?: OpenClawPluginHttpRouteMatch;
gatewayRuntimeScopeSurface?: OpenClawPluginGatewayRuntimeScopeSurface;
replaceExisting?: boolean;
};