feat: add bundled admin HTTP RPC plugin

This commit is contained in:
Peter Steinberger
2026-05-15 11:28:44 +01:00
parent dfeaf6f7cf
commit 764cfd5552
14 changed files with 587 additions and 6 deletions

4
.github/labeler.yml vendored
View File

@@ -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:

View File

@@ -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<Record<string, unknown>> = [];
plugin.register({
registerHttpRoute(route) {
routes.push(route as unknown as Record<string, unknown>);
},
} as Parameters<typeof plugin.register>[0]);
expect(routes).toHaveLength(1);
expect(routes[0]).toMatchObject({
path: "/api/v1/admin/rpc",
auth: "gateway",
match: "exact",
gatewayRuntimeScopeSurface: "trusted-operator",
});
});
});

View File

@@ -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,
});
},
});

View File

@@ -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": {}
}
}

View File

@@ -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"
]
}
}

View File

@@ -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<string, string | number | readonly string[]>;
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();
});
});

View File

@@ -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<string, unknown> }
| { id: string; ok: false; error: RpcError; meta?: Record<string, unknown> };
type ParsedRequest = {
id: string;
method: string;
params?: unknown;
};
function isRecord(value: unknown): value is Record<string, unknown> {
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<RpcResponse> {
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<boolean> {
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;
}

View File

@@ -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<string, readonly string[]>;
const ADMIN_HTTP_RPC_ALLOWED_METHODS: ReadonlySet<string> = 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);
}

View File

@@ -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"
]
}

6
pnpm-lock.yaml generated
View File

@@ -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':

View File

@@ -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];

View File

@@ -24,6 +24,7 @@ export {
resolveHttpSenderIsOwner,
resolveOpenAiCompatibleHttpOperatorScopes,
resolveOpenAiCompatibleHttpSenderIsOwner,
resolveSharedSecretHttpOperatorScopes,
resolveTrustedHttpOperatorScopes,
type AuthorizedGatewayHttpRequest,
type GatewayHttpRequestAuthCheckResult,

View File

@@ -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;

View File

@@ -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,
},
{