Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
33
extensions/admin-http-rpc/index.test.ts
Normal file
33
extensions/admin-http-rpc/index.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// Admin Http Rpc tests cover index plugin behavior.
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
21
extensions/admin-http-rpc/index.ts
Normal file
21
extensions/admin-http-rpc/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Admin HTTP RPC plugin entry. It exposes a trusted gateway-authenticated HTTP
|
||||
* endpoint for the explicit admin method allowlist.
|
||||
*/
|
||||
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,
|
||||
});
|
||||
},
|
||||
});
|
||||
15
extensions/admin-http-rpc/openclaw.plugin.json
Normal file
15
extensions/admin-http-rpc/openclaw.plugin.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
15
extensions/admin-http-rpc/package.json
Normal file
15
extensions/admin-http-rpc/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@openclaw/admin-http-rpc",
|
||||
"version": "2026.6.11",
|
||||
"private": true,
|
||||
"description": "OpenClaw admin HTTP RPC endpoint",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
188
extensions/admin-http-rpc/src/handler.test.ts
Normal file
188
extensions/admin-http-rpc/src/handler.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
// Admin Http Rpc tests cover handler plugin behavior.
|
||||
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.each([
|
||||
["web.login.start", { force: true, timeoutMs: 1000 }],
|
||||
["web.login.wait", { timeoutMs: 1000 }],
|
||||
] as const)(
|
||||
"allows web QR login method %s through the authenticated plugin request scope",
|
||||
async (method, params) => {
|
||||
dispatchGatewayMethod.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
payload: { status: "ok" },
|
||||
});
|
||||
|
||||
const result = await invoke({
|
||||
id: "web-login",
|
||||
method,
|
||||
params,
|
||||
});
|
||||
|
||||
expect(dispatchGatewayMethod).toHaveBeenCalledWith(method, params);
|
||||
expect(result.captured.statusCode).toBe(200);
|
||||
expect(result.json).toEqual({
|
||||
id: "web-login",
|
||||
ok: true,
|
||||
payload: { status: "ok" },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
238
extensions/admin-http-rpc/src/handler.ts
Normal file
238
extensions/admin-http-rpc/src/handler.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* HTTP handler for the Admin RPC endpoint. It validates JSON requests, enforces
|
||||
* the method allowlist, dispatches gateway methods, and maps errors to HTTP.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { dispatchGatewayMethod } from "openclaw/plugin-sdk/gateway-method-runtime";
|
||||
import { isRecord } from "openclaw/plugin-sdk/string-coerce-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 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.hasOwn(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",
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle one gateway-authenticated Admin HTTP RPC request. */
|
||||
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;
|
||||
}
|
||||
69
extensions/admin-http-rpc/src/methods.ts
Normal file
69
extensions/admin-http-rpc/src/methods.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Method allowlist for Admin HTTP RPC. Only methods listed here can cross the
|
||||
* trusted operator HTTP surface.
|
||||
*/
|
||||
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"],
|
||||
web: ["web.login.start", "web.login.wait"],
|
||||
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(),
|
||||
);
|
||||
|
||||
/** Return whether an admin RPC method is exposed over HTTP. */
|
||||
export function isAdminHttpRpcAllowedMethod(method: string): boolean {
|
||||
return ADMIN_HTTP_RPC_ALLOWED_METHODS.has(method);
|
||||
}
|
||||
|
||||
/** List all admin RPC methods exposed over HTTP. */
|
||||
export function listAdminHttpRpcAllowedMethods(): string[] {
|
||||
return Array.from(ADMIN_HTTP_RPC_ALLOWED_METHODS);
|
||||
}
|
||||
16
extensions/admin-http-rpc/tsconfig.json
Normal file
16
extensions/admin-http-rpc/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user