Files
openclaw/src/gateway/server-methods/plugin-host-hooks.ts

243 lines
8.3 KiB
TypeScript

import { formatErrorMessage } from "../../infra/errors.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { isPluginJsonValue } from "../../plugins/host-hooks.js";
import { getActivePluginRegistry } from "../../plugins/runtime.js";
import {
validateJsonSchemaValue,
type JsonSchemaValidationError,
type JsonSchemaValue,
} from "../../plugins/schema-validator.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import { ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE } from "../operator-scopes.js";
import {
ErrorCodes,
errorShape,
formatValidationErrors,
validatePluginsSessionActionParams,
validatePluginsSessionActionResult,
validatePluginsUiDescriptorsParams,
} from "../protocol/index.js";
import type { GatewayRequestHandlers } from "./types.js";
const log = createSubsystemLogger("gateway/plugin-host-hooks");
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function formatSessionActionPayloadSchemaErrors(errors: JsonSchemaValidationError[]): string {
return errors.map((error) => error.text).join("; ");
}
function validatePluginSessionActionJsonFields(
result: Record<string, unknown>,
): string | undefined {
for (const field of ["result", "reply", "details"] as const) {
if (result[field] !== undefined && !isPluginJsonValue(result[field])) {
return `plugin session action ${field} must be JSON-compatible`;
}
}
return undefined;
}
export const pluginHostHookHandlers: GatewayRequestHandlers = {
"plugins.uiDescriptors": ({ params, respond }) => {
if (!validatePluginsUiDescriptorsParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid plugins.uiDescriptors params: ${formatValidationErrors(validatePluginsUiDescriptorsParams.errors)}`,
),
);
return;
}
const descriptors = (getActivePluginRegistry()?.controlUiDescriptors ?? []).map((entry) =>
Object.assign({}, entry.descriptor, {
pluginId: entry.pluginId,
pluginName: entry.pluginName,
}),
);
respond(true, { ok: true, descriptors }, undefined);
},
"plugins.sessionAction": async ({ params, client, respond }) => {
if (!validatePluginsSessionActionParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid plugins.sessionAction params: ${formatValidationErrors(validatePluginsSessionActionParams.errors)}`,
),
);
return;
}
const pluginId = normalizeOptionalString(params.pluginId);
const actionId = normalizeOptionalString(params.actionId);
const sessionKey = normalizeOptionalString(params.sessionKey);
if (!pluginId || !actionId) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"plugins.sessionAction pluginId and actionId must be non-empty",
),
);
return;
}
const registry = getActivePluginRegistry();
const pluginLoaded = Boolean(
registry?.plugins.some((plugin) => plugin.id === pluginId && plugin.status === "loaded"),
);
const registration = (registry?.sessionActions ?? []).find(
(entry) => entry.pluginId === pluginId && entry.action.id === actionId,
);
if (!registration || !pluginLoaded) {
respond(
false,
undefined,
errorShape(
ErrorCodes.UNAVAILABLE,
`unknown plugin session action: ${pluginId}/${actionId}`,
),
);
return;
}
const scopes = Array.isArray(client?.connect.scopes) ? client.connect.scopes : [];
const hasAdmin = scopes.includes(ADMIN_SCOPE);
const requiredScopes =
registration.action.requiredScopes && registration.action.requiredScopes.length > 0
? registration.action.requiredScopes
: [WRITE_SCOPE];
const missingScope = requiredScopes.find(
(scope) =>
!hasAdmin &&
!scopes.includes(scope) &&
!(scope === READ_SCOPE && scopes.includes(WRITE_SCOPE)),
);
if (missingScope) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `missing scope: ${missingScope}`),
);
return;
}
try {
if (params.payload !== undefined && !isPluginJsonValue(params.payload)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"plugin session action payload must be JSON-compatible",
),
);
return;
}
if (registration.action.schema !== undefined) {
if (
typeof registration.action.schema !== "boolean" &&
!isRecord(registration.action.schema)
) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"plugin session action schema must be an object or boolean",
),
);
return;
}
const validation = validateJsonSchemaValue({
schema: registration.action.schema as JsonSchemaValue,
cacheKey: `plugin-session-action:${pluginId}:${actionId}`,
value: params.payload,
});
if (!validation.ok) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`plugin session action payload does not match schema: ${formatSessionActionPayloadSchemaErrors(validation.errors)}`,
),
);
return;
}
}
const result = await registration.action.handler({
pluginId,
actionId,
...(sessionKey ? { sessionKey } : {}),
...(params.payload !== undefined ? { payload: params.payload } : {}),
client: {
...(client?.connId ? { connId: client.connId } : {}),
scopes: [...scopes],
},
});
if (result !== undefined && !isRecord(result)) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "plugin session action result must be an object"),
);
return;
}
const wireResult = result?.ok === false ? result : { ok: true as const, ...result };
if (!validatePluginsSessionActionResult(wireResult)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid plugin session action result: ${formatValidationErrors(validatePluginsSessionActionResult.errors)}`,
),
);
return;
}
const jsonFieldError = result ? validatePluginSessionActionJsonFields(result) : undefined;
if (jsonFieldError) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, jsonFieldError));
return;
}
if (!wireResult.ok) {
// Plugin-declared action failures are returned as a successful RPC
// with `ok: false` per PluginsSessionActionResultSchema. Reserve
// transport errorShape for protocol-level failures (validation,
// schema mismatch, dispatch error). Distinguishing these in the
// wire shape lets callers handle plugin failures (often retryable
// or user-facing) differently from transport errors (operator
// diagnostics).
respond(
true,
{
ok: false,
error: wireResult.error,
...(wireResult.code !== undefined ? { code: wireResult.code } : {}),
...(wireResult.details !== undefined ? { details: wireResult.details } : {}),
},
undefined,
);
return;
}
respond(true, {
ok: true,
...(wireResult.result !== undefined ? { result: wireResult.result } : {}),
...(wireResult.continueAgent !== undefined
? { continueAgent: wireResult.continueAgent }
: {}),
...(wireResult.reply !== undefined ? { reply: wireResult.reply } : {}),
});
} catch (error) {
log.warn(
`plugin session action failed plugin=${pluginId} action=${actionId}: ${formatErrorMessage(error)}`,
);
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "plugin session action failed"));
}
},
};