From 764cfd5552cf3e71ebffae66c0b70455b5f85c1f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 15 May 2026 11:28:44 +0100 Subject: [PATCH] feat: add bundled admin HTTP RPC plugin --- .github/labeler.yml | 4 + extensions/admin-http-rpc/index.test.ts | 32 +++ extensions/admin-http-rpc/index.ts | 17 ++ .../admin-http-rpc/openclaw.plugin.json | 15 ++ extensions/admin-http-rpc/package.json | 15 ++ extensions/admin-http-rpc/src/handler.test.ts | 160 ++++++++++++ extensions/admin-http-rpc/src/handler.ts | 238 ++++++++++++++++++ extensions/admin-http-rpc/src/methods.ts | 62 +++++ extensions/admin-http-rpc/tsconfig.json | 16 ++ pnpm-lock.yaml | 6 + src/gateway/http-auth-utils.ts | 21 +- src/gateway/http-utils.ts | 1 + src/security/audit-extra.sync.ts | 4 +- src/security/audit-gateway-http-auth.test.ts | 2 + 14 files changed, 587 insertions(+), 6 deletions(-) create mode 100644 extensions/admin-http-rpc/index.test.ts create mode 100644 extensions/admin-http-rpc/index.ts create mode 100644 extensions/admin-http-rpc/openclaw.plugin.json create mode 100644 extensions/admin-http-rpc/package.json create mode 100644 extensions/admin-http-rpc/src/handler.test.ts create mode 100644 extensions/admin-http-rpc/src/handler.ts create mode 100644 extensions/admin-http-rpc/src/methods.ts create mode 100644 extensions/admin-http-rpc/tsconfig.json diff --git a/.github/labeler.yml b/.github/labeler.yml index 12f00426eee..597e778efec 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -244,6 +244,10 @@ - "docs/gateway/security.md" - "security/**" +"extensions: admin-http-rpc": + - changed-files: + - any-glob-to-any-file: + - "extensions/admin-http-rpc/**" "extensions: copilot-proxy": - changed-files: - any-glob-to-any-file: diff --git a/extensions/admin-http-rpc/index.test.ts b/extensions/admin-http-rpc/index.test.ts new file mode 100644 index 00000000000..d762b5b182e --- /dev/null +++ b/extensions/admin-http-rpc/index.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import plugin from "./index.js"; +import manifest from "./openclaw.plugin.json" with { type: "json" }; + +describe("admin-http-rpc plugin entry", () => { + it("stays startup-off until the plugin entry is explicitly enabled", () => { + expect(manifest.activation).toEqual({ + onStartup: false, + onConfigPaths: ["plugins.entries.admin-http-rpc"], + }); + expect(manifest.contracts).toEqual({ + gatewayMethodDispatch: ["authenticated-request"], + }); + }); + + it("registers one trusted gateway HTTP route", () => { + const routes: Array> = []; + plugin.register({ + registerHttpRoute(route) { + routes.push(route as unknown as Record); + }, + } as Parameters[0]); + + expect(routes).toHaveLength(1); + expect(routes[0]).toMatchObject({ + path: "/api/v1/admin/rpc", + auth: "gateway", + match: "exact", + gatewayRuntimeScopeSurface: "trusted-operator", + }); + }); +}); diff --git a/extensions/admin-http-rpc/index.ts b/extensions/admin-http-rpc/index.ts new file mode 100644 index 00000000000..bf56992e3b6 --- /dev/null +++ b/extensions/admin-http-rpc/index.ts @@ -0,0 +1,17 @@ +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import { handleAdminHttpRpcRequest } from "./src/handler.js"; + +export default definePluginEntry({ + id: "admin-http-rpc", + name: "Admin HTTP RPC", + description: "Expose selected Gateway admin RPC methods over HTTP", + register(api) { + api.registerHttpRoute({ + path: "/api/v1/admin/rpc", + auth: "gateway", + match: "exact", + gatewayRuntimeScopeSurface: "trusted-operator", + handler: handleAdminHttpRpcRequest, + }); + }, +}); diff --git a/extensions/admin-http-rpc/openclaw.plugin.json b/extensions/admin-http-rpc/openclaw.plugin.json new file mode 100644 index 00000000000..158ec1f8131 --- /dev/null +++ b/extensions/admin-http-rpc/openclaw.plugin.json @@ -0,0 +1,15 @@ +{ + "id": "admin-http-rpc", + "activation": { + "onStartup": false, + "onConfigPaths": ["plugins.entries.admin-http-rpc"] + }, + "contracts": { + "gatewayMethodDispatch": ["authenticated-request"] + }, + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/admin-http-rpc/package.json b/extensions/admin-http-rpc/package.json new file mode 100644 index 00000000000..24922025d48 --- /dev/null +++ b/extensions/admin-http-rpc/package.json @@ -0,0 +1,15 @@ +{ + "name": "@openclaw/admin-http-rpc", + "version": "2026.5.14", + "private": true, + "description": "OpenClaw admin HTTP RPC endpoint", + "type": "module", + "devDependencies": { + "@openclaw/plugin-sdk": "workspace:*" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/admin-http-rpc/src/handler.test.ts b/extensions/admin-http-rpc/src/handler.test.ts new file mode 100644 index 00000000000..7b05a3e9131 --- /dev/null +++ b/extensions/admin-http-rpc/src/handler.test.ts @@ -0,0 +1,160 @@ +import { Readable } from "node:stream"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { handleAdminHttpRpcRequest } from "./handler.js"; +import { listAdminHttpRpcAllowedMethods } from "./methods.js"; + +const { dispatchGatewayMethod } = vi.hoisted(() => ({ + dispatchGatewayMethod: vi.fn(), +})); + +vi.mock("openclaw/plugin-sdk/gateway-method-runtime", () => ({ + dispatchGatewayMethod, +})); + +type CapturedResponse = { + statusCode: number; + headers: Record; + body: string; +}; + +function createRequest(body: unknown, method = "POST") { + const req = Readable.from([typeof body === "string" ? body : JSON.stringify(body)]); + Object.assign(req, { + method, + url: "/api/v1/admin/rpc", + headers: { + "content-type": "application/json", + }, + }); + return req as import("node:http").IncomingMessage; +} + +function createResponse() { + const captured: CapturedResponse = { + statusCode: 200, + headers: {}, + body: "", + }; + const res = { + get statusCode() { + return captured.statusCode; + }, + set statusCode(value: number) { + captured.statusCode = value; + }, + setHeader(name: string, value: string | number | readonly string[]) { + captured.headers[name.toLowerCase()] = value; + }, + end(chunk?: string | Buffer) { + captured.body = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : (chunk ?? ""); + }, + } as import("node:http").ServerResponse; + return { res, captured }; +} + +async function invoke(body: unknown, method = "POST") { + const { res, captured } = createResponse(); + const handled = await handleAdminHttpRpcRequest(createRequest(body, method), res); + return { + handled, + captured, + json: captured.body ? (JSON.parse(captured.body) as unknown) : undefined, + }; +} + +describe("admin-http-rpc plugin handler", () => { + beforeEach(() => { + dispatchGatewayMethod.mockReset(); + }); + + it("returns the allowlist without dispatching through the Gateway", async () => { + const result = await invoke({ id: "1", method: "commands.list" }); + + expect(result.handled).toBe(true); + expect(result.captured.statusCode).toBe(200); + expect(result.json).toEqual({ + id: "1", + ok: true, + payload: { + methods: listAdminHttpRpcAllowedMethods(), + }, + }); + expect(dispatchGatewayMethod).not.toHaveBeenCalled(); + }); + + it("dispatches allowed methods through the authenticated plugin request scope", async () => { + dispatchGatewayMethod.mockResolvedValueOnce({ + ok: true, + payload: { status: "ok" }, + meta: { requestId: "abc" }, + }); + + const result = await invoke({ + id: "cfg", + method: "config.get", + params: { path: "gateway" }, + }); + + expect(dispatchGatewayMethod).toHaveBeenCalledWith("config.get", { path: "gateway" }); + expect(result.captured.statusCode).toBe(200); + expect(result.json).toEqual({ + id: "cfg", + ok: true, + payload: { status: "ok" }, + meta: { requestId: "abc" }, + }); + }); + + it("rejects methods outside the admin HTTP RPC allowlist", async () => { + const result = await invoke({ id: "bad", method: "sessions.send" }); + + expect(dispatchGatewayMethod).not.toHaveBeenCalled(); + expect(result.captured.statusCode).toBe(400); + expect(result.json).toEqual({ + id: "bad", + ok: false, + error: { + code: "INVALID_REQUEST", + message: "admin HTTP RPC method is not supported: sessions.send", + }, + }); + }); + + it("maps Gateway errors to HTTP status codes", async () => { + dispatchGatewayMethod.mockResolvedValueOnce({ + ok: false, + error: { code: "NOT_PAIRED", message: "pair first" }, + }); + + const result = await invoke({ id: "node", method: "node.list" }); + + expect(result.captured.statusCode).toBe(409); + expect(result.json).toEqual({ + id: "node", + ok: false, + error: { code: "NOT_PAIRED", message: "pair first" }, + }); + }); + + it("rejects invalid request bodies before dispatch", async () => { + const result = await invoke({ id: "missing" }); + + expect(result.captured.statusCode).toBe(400); + expect(result.json).toEqual({ + ok: false, + error: { + type: "invalid_request", + message: "method must be a non-empty string", + }, + }); + expect(dispatchGatewayMethod).not.toHaveBeenCalled(); + }); + + it("only accepts POST", async () => { + const result = await invoke({ method: "status" }, "GET"); + + expect(result.captured.statusCode).toBe(405); + expect(result.captured.headers.allow).toBe("POST"); + expect(dispatchGatewayMethod).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/admin-http-rpc/src/handler.ts b/extensions/admin-http-rpc/src/handler.ts new file mode 100644 index 00000000000..336f5a6af8b --- /dev/null +++ b/extensions/admin-http-rpc/src/handler.ts @@ -0,0 +1,238 @@ +import { randomUUID } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { dispatchGatewayMethod } from "openclaw/plugin-sdk/gateway-method-runtime"; +import { isAdminHttpRpcAllowedMethod, listAdminHttpRpcAllowedMethods } from "./methods.js"; + +const DEFAULT_RPC_BODY_BYTES = 1024 * 1024; + +const ErrorCodes = { + AGENT_TIMEOUT: "AGENT_TIMEOUT", + APPROVAL_NOT_FOUND: "APPROVAL_NOT_FOUND", + INVALID_REQUEST: "INVALID_REQUEST", + NOT_LINKED: "NOT_LINKED", + NOT_PAIRED: "NOT_PAIRED", + UNAVAILABLE: "UNAVAILABLE", +} as const; + +type RpcBody = { + id?: unknown; + method?: unknown; + params?: unknown; +}; + +type RpcError = { + code: string; + message: string; + details?: unknown; + retryable?: boolean; + retryAfterMs?: number; +}; + +type RpcResponse = + | { id: string; ok: true; payload: unknown; meta?: Record } + | { id: string; ok: false; error: RpcError; meta?: Record }; + +type ParsedRequest = { + id: string; + method: string; + params?: unknown; +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function createError(code: string, message: string): RpcError { + return { code, message }; +} + +function rpcHttpStatus(response: RpcResponse): number { + if (response.ok) { + return 200; + } + switch (response.error.code) { + case ErrorCodes.INVALID_REQUEST: + return 400; + case ErrorCodes.APPROVAL_NOT_FOUND: + return 404; + case ErrorCodes.UNAVAILABLE: + return 503; + case ErrorCodes.AGENT_TIMEOUT: + return 504; + case ErrorCodes.NOT_LINKED: + case ErrorCodes.NOT_PAIRED: + return 409; + default: + return 500; + } +} + +function sendJson(res: ServerResponse, status: number, body: unknown): void { + res.statusCode = status; + res.setHeader("Cache-Control", "no-store"); + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify(body)); +} + +function sendError(res: ServerResponse, status: number, error: { type: string; message: string }) { + sendJson(res, status, { ok: false, error }); +} + +async function readJsonBody( + req: IncomingMessage, + maxBytes: number, +): Promise<{ ok: true; value: unknown } | { ok: false; status: number; message: string }> { + const chunks: Buffer[] = []; + let totalBytes = 0; + try { + for await (const chunk of req) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + totalBytes += buffer.byteLength; + if (totalBytes > maxBytes) { + return { ok: false, status: 413, message: "Payload too large" }; + } + chunks.push(buffer); + } + } catch { + return { ok: false, status: 400, message: "failed to read request body" }; + } + + const raw = Buffer.concat(chunks).toString("utf8"); + if (!raw.trim()) { + return { ok: false, status: 400, message: "request body must be JSON" }; + } + try { + return { ok: true, value: JSON.parse(raw) }; + } catch { + return { ok: false, status: 400, message: "request body must be valid JSON" }; + } +} + +function readRpcRequestBody(body: unknown): + | { ok: true; request: ParsedRequest } + | { + ok: false; + message: string; + } { + if (!isRecord(body)) { + return { ok: false, message: "request body must be an object" }; + } + const rpcBody = body as RpcBody; + if (typeof rpcBody.method !== "string" || rpcBody.method.trim().length === 0) { + return { ok: false, message: "method must be a non-empty string" }; + } + const id = + typeof rpcBody.id === "string" && rpcBody.id.trim().length > 0 + ? rpcBody.id.trim() + : randomUUID(); + return { + ok: true, + request: { + id, + method: rpcBody.method.trim(), + ...(Object.prototype.hasOwnProperty.call(rpcBody, "params") + ? { params: rpcBody.params } + : {}), + }, + }; +} + +function methodNotAllowed(id: string, method: string): RpcResponse { + return { + id, + ok: false, + error: createError( + ErrorCodes.INVALID_REQUEST, + `admin HTTP RPC method is not supported: ${method}`, + ), + }; +} + +function commandsList(id: string): RpcResponse { + return { + id, + ok: true, + payload: { + methods: listAdminHttpRpcAllowedMethods(), + }, + }; +} + +async function dispatchAdminRpc(request: ParsedRequest): Promise { + try { + const response = await dispatchGatewayMethod(request.method, request.params); + if (response.ok) { + return { + id: request.id, + ok: true, + payload: response.payload, + ...(response.meta ? { meta: response.meta } : {}), + }; + } + return { + id: request.id, + ok: false, + error: + response.error ?? + createError(ErrorCodes.UNAVAILABLE, "gateway method failed before returning a response"), + ...(response.meta ? { meta: response.meta } : {}), + }; + } catch { + return { + id: request.id, + ok: false, + error: createError( + ErrorCodes.UNAVAILABLE, + "gateway method failed before returning a response", + ), + }; + } +} + +export async function handleAdminHttpRpcRequest( + req: IncomingMessage, + res: ServerResponse, +): Promise { + if ((req.method ?? "GET").toUpperCase() !== "POST") { + res.setHeader("Allow", "POST"); + sendError(res, 405, { + type: "method_not_allowed", + message: "Method Not Allowed", + }); + return true; + } + + const body = await readJsonBody(req, DEFAULT_RPC_BODY_BYTES); + if (!body.ok) { + sendError(res, body.status, { + type: "invalid_request", + message: body.message, + }); + return true; + } + + const parsed = readRpcRequestBody(body.value); + if (!parsed.ok) { + sendError(res, 400, { + type: "invalid_request", + message: parsed.message, + }); + return true; + } + + if (!isAdminHttpRpcAllowedMethod(parsed.request.method)) { + const response = methodNotAllowed(parsed.request.id, parsed.request.method); + sendJson(res, rpcHttpStatus(response), response); + return true; + } + + if (parsed.request.method === "commands.list") { + const response = commandsList(parsed.request.id); + sendJson(res, 200, response); + return true; + } + + const response = await dispatchAdminRpc(parsed.request); + sendJson(res, rpcHttpStatus(response), response); + return true; +} diff --git a/extensions/admin-http-rpc/src/methods.ts b/extensions/admin-http-rpc/src/methods.ts new file mode 100644 index 00000000000..4dbd38901e0 --- /dev/null +++ b/extensions/admin-http-rpc/src/methods.ts @@ -0,0 +1,62 @@ +const ADMIN_HTTP_RPC_ALLOWED_METHOD_GROUPS = { + gateway: [ + "health", + "status", + "logs.tail", + "usage.status", + "usage.cost", + "gateway.restart.request", + ], + discovery: ["commands.list"], + config: [ + "config.get", + "config.schema", + "config.schema.lookup", + "config.set", + "config.patch", + "config.apply", + ], + channels: ["channels.status", "channels.start", "channels.stop", "channels.logout"], + models: ["models.list", "models.authStatus"], + agents: ["agents.list", "agents.create", "agents.update", "agents.delete"], + approvals: [ + "exec.approvals.get", + "exec.approvals.set", + "exec.approvals.node.get", + "exec.approvals.node.set", + ], + cron: [ + "cron.status", + "cron.list", + "cron.get", + "cron.runs", + "cron.add", + "cron.update", + "cron.remove", + "cron.run", + ], + devices: ["device.pair.list", "device.pair.approve", "device.pair.reject", "device.pair.remove"], + nodes: [ + "node.list", + "node.describe", + "node.pair.list", + "node.pair.approve", + "node.pair.reject", + "node.pair.remove", + "node.rename", + ], + tasks: ["tasks.list", "tasks.get", "tasks.cancel"], + diagnostics: ["doctor.memory.status", "update.status"], +} as const satisfies Record; + +const ADMIN_HTTP_RPC_ALLOWED_METHODS: ReadonlySet = new Set( + Object.values(ADMIN_HTTP_RPC_ALLOWED_METHOD_GROUPS).flat(), +); + +export function isAdminHttpRpcAllowedMethod(method: string): boolean { + return ADMIN_HTTP_RPC_ALLOWED_METHODS.has(method); +} + +export function listAdminHttpRpcAllowedMethods(): string[] { + return Array.from(ADMIN_HTTP_RPC_ALLOWED_METHODS); +} diff --git a/extensions/admin-http-rpc/tsconfig.json b/extensions/admin-http-rpc/tsconfig.json new file mode 100644 index 00000000000..b8a85a99ac3 --- /dev/null +++ b/extensions/admin-http-rpc/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../tsconfig.package-boundary.base.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["./*.ts", "./src/**/*.ts"], + "exclude": [ + "./**/*.test.ts", + "./dist/**", + "./node_modules/**", + "./src/test-support/**", + "./src/**/*test-helpers.ts", + "./src/**/*test-harness.ts", + "./src/**/*test-support.ts" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ce27918636..e6f00fb2a62 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -288,6 +288,12 @@ importers: specifier: workspace:* version: link:../../packages/plugin-sdk + extensions/admin-http-rpc: + devDependencies: + '@openclaw/plugin-sdk': + specifier: workspace:* + version: link:../../packages/plugin-sdk + extensions/alibaba: devDependencies: '@openclaw/plugin-sdk': diff --git a/src/gateway/http-auth-utils.ts b/src/gateway/http-auth-utils.ts index c1db840b17c..01823886cf6 100644 --- a/src/gateway/http-auth-utils.ts +++ b/src/gateway/http-auth-utils.ts @@ -150,7 +150,11 @@ export async function authorizeScopedGatewayHttpRequestOrReply(params: { req: IncomingMessage, requestAuth: AuthorizedGatewayHttpRequest, ) => string[]; -}): Promise<{ cfg: OpenClawConfig; requestAuth: AuthorizedGatewayHttpRequest } | null> { +}): Promise<{ + cfg: OpenClawConfig; + requestAuth: AuthorizedGatewayHttpRequest; + operatorScopes: string[]; +} | null> { const cfg = getRuntimeConfig(); const requestAuth = await authorizeGatewayHttpRequestOrReply({ req: params.req, @@ -164,14 +168,14 @@ export async function authorizeScopedGatewayHttpRequestOrReply(params: { return null; } - const requestedScopes = params.resolveOperatorScopes(params.req, requestAuth); - const scopeAuth = authorizeOperatorScopesForMethod(params.operatorMethod, requestedScopes); + const operatorScopes = params.resolveOperatorScopes(params.req, requestAuth); + const scopeAuth = authorizeOperatorScopesForMethod(params.operatorMethod, operatorScopes); if (!scopeAuth.allowed) { sendMissingScopeForbidden(params.res, scopeAuth.missingScope); return null; } - return { cfg, requestAuth }; + return { cfg, requestAuth, operatorScopes }; } export function isGatewayBearerHttpRequest( @@ -212,10 +216,17 @@ export function resolveTrustedHttpOperatorScopes( export function resolveOpenAiCompatibleHttpOperatorScopes( req: IncomingMessage, requestAuth: AuthorizedGatewayHttpRequest, +): string[] { + return resolveSharedSecretHttpOperatorScopes(req, requestAuth); +} + +export function resolveSharedSecretHttpOperatorScopes( + req: IncomingMessage, + requestAuth: AuthorizedGatewayHttpRequest, ): string[] { if (usesSharedSecretGatewayMethod(requestAuth.authMethod)) { // Shared-secret HTTP bearer auth is a documented trusted-operator surface - // for the compat APIs and direct /tools/invoke. This is designed-as-is: + // for direct HTTP surfaces that opt into it. This is designed-as-is: // token/password auth proves possession of the gateway operator secret, not // a narrower per-request scope identity, so restore the normal defaults. return [...CLI_DEFAULT_OPERATOR_SCOPES]; diff --git a/src/gateway/http-utils.ts b/src/gateway/http-utils.ts index 857784805a2..4ebaf00d54f 100644 --- a/src/gateway/http-utils.ts +++ b/src/gateway/http-utils.ts @@ -24,6 +24,7 @@ export { resolveHttpSenderIsOwner, resolveOpenAiCompatibleHttpOperatorScopes, resolveOpenAiCompatibleHttpSenderIsOwner, + resolveSharedSecretHttpOperatorScopes, resolveTrustedHttpOperatorScopes, type AuthorizedGatewayHttpRequest, type GatewayHttpRequestAuthCheckResult, diff --git a/src/security/audit-extra.sync.ts b/src/security/audit-extra.sync.ts index cf398895bc5..18efb337270 100644 --- a/src/security/audit-extra.sync.ts +++ b/src/security/audit-extra.sync.ts @@ -652,10 +652,12 @@ export function collectGatewayHttpNoAuthFindings( const chatCompletionsEnabled = cfg.gateway?.http?.endpoints?.chatCompletions?.enabled === true; const responsesEnabled = cfg.gateway?.http?.endpoints?.responses?.enabled === true; + const adminHttpRpcEnabled = cfg.plugins?.entries?.["admin-http-rpc"]?.enabled === true; const enabledEndpoints = [ "/tools/invoke", chatCompletionsEnabled ? "/v1/chat/completions" : null, responsesEnabled ? "/v1/responses" : null, + adminHttpRpcEnabled ? "/api/v1/admin/rpc" : null, ].filter((entry): entry is string => Boolean(entry)); const remoteExposure = isGatewayRemotelyExposed(cfg); @@ -667,7 +669,7 @@ export function collectGatewayHttpNoAuthFindings( `gateway.auth.mode="none" leaves ${enabledEndpoints.join(", ")} callable without a shared secret. ` + "Treat this as trusted-local only and avoid exposing the gateway beyond loopback.", remediation: - "Set gateway.auth.mode to token/password (recommended). If you intentionally keep mode=none, keep gateway.bind=loopback and disable optional HTTP endpoints.", + "Set gateway.auth.mode to token/password (recommended). If you intentionally keep mode=none, keep gateway.bind=loopback and disable optional HTTP endpoints/plugins.", }); return findings; diff --git a/src/security/audit-gateway-http-auth.test.ts b/src/security/audit-gateway-http-auth.test.ts index 361b347b941..34d2675ffe8 100644 --- a/src/security/audit-gateway-http-auth.test.ts +++ b/src/security/audit-gateway-http-auth.test.ts @@ -39,8 +39,10 @@ describe("security audit gateway HTTP auth findings", () => { auth: { mode: "none" }, http: { endpoints: { responses: { enabled: true } } }, }, + plugins: { entries: { "admin-http-rpc": { enabled: true } } }, } satisfies OpenClawConfig, expectedFinding: { checkId: "gateway.http.no_auth", severity: "critical" as const }, + detailIncludes: ["/api/v1/admin/rpc"], env: {} as NodeJS.ProcessEnv, }, {