fix(gateway): validate plugin descriptors and compact refresh

This commit is contained in:
Vincent Koc
2026-06-19 22:16:29 +02:00
parent 0f18e82932
commit 6f5fdb1e6b
5 changed files with 128 additions and 6 deletions

View File

@@ -210,7 +210,9 @@ import {
PluginsSessionActionParamsSchema,
PluginsSessionActionResultSchema,
type PluginsUiDescriptorsParams,
type PluginsUiDescriptorsResult,
PluginsUiDescriptorsParamsSchema,
PluginsUiDescriptorsResultSchema,
ErrorCodes,
type EnvironmentSummary,
EnvironmentSummarySchema,
@@ -880,6 +882,9 @@ export const validatePluginApprovalResolveParams = lazyCompile<PluginApprovalRes
export const validatePluginsUiDescriptorsParams = lazyCompile<PluginsUiDescriptorsParams>(
PluginsUiDescriptorsParamsSchema,
);
export const validatePluginsUiDescriptorsResult = lazyCompile<PluginsUiDescriptorsResult>(
PluginsUiDescriptorsResultSchema,
);
export const validatePluginsSessionActionParams = lazyCompile<PluginsSessionActionParams>(
PluginsSessionActionParamsSchema,
);
@@ -1133,6 +1138,7 @@ export {
PluginsSessionActionParamsSchema,
PluginsSessionActionResultSchema,
PluginsUiDescriptorsParamsSchema,
PluginsUiDescriptorsResultSchema,
ModelsListParamsSchema,
SkillsStatusParamsSchema,
ToolsCatalogParamsSchema,

View File

@@ -993,7 +993,10 @@ describe("session accessor file-backed seam", () => {
expect(updatedEntry?.outputTokens).toBeUndefined();
expect(updatedEntry?.totalTokens).toBeUndefined();
expect(updatedEntry?.totalTokensFresh).toBeUndefined();
expect(updates).toEqual([{ sessionFile: archived }]);
expect(updates).toEqual([
{ sessionFile: archived },
{ sessionFile: fs.realpathSync(manualTranscriptPath) },
]);
});
it("keeps retained messages reachable through an out-of-window label", async () => {

View File

@@ -1148,6 +1148,7 @@ async function replaceTranscriptForManualCompact(
throw err;
}
emitSessionTranscriptUpdate({ sessionFile: archived });
emitSessionTranscriptUpdate({ sessionFile: filePath });
return archived;
}

View File

@@ -9,6 +9,7 @@ import {
validatePluginsSessionActionParams,
validatePluginsSessionActionResult,
validatePluginsUiDescriptorsParams,
validatePluginsUiDescriptorsResult,
} from "../../../packages/gateway-protocol/src/index.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
@@ -54,13 +55,44 @@ export const pluginHostHookHandlers: GatewayRequestHandlers = {
);
return;
}
const descriptors = (getActivePluginRegistry()?.controlUiDescriptors ?? []).map((entry) =>
Object.assign({}, entry.descriptor, {
const descriptors = (getActivePluginRegistry()?.controlUiDescriptors ?? []).map((entry) => {
const descriptor: Record<string, unknown> = {
id: entry.descriptor.id,
pluginId: entry.pluginId,
pluginName: entry.pluginName,
}),
);
respond(true, { ok: true, descriptors }, undefined);
surface: entry.descriptor.surface,
label: entry.descriptor.label,
};
if (entry.descriptor.description !== undefined) {
descriptor.description = entry.descriptor.description;
}
if (entry.descriptor.placement !== undefined) {
descriptor.placement = entry.descriptor.placement;
}
if (entry.descriptor.schema !== undefined) {
descriptor.schema = entry.descriptor.schema;
}
if (entry.descriptor.requiredScopes !== undefined) {
descriptor.requiredScopes = entry.descriptor.requiredScopes;
}
return descriptor;
});
const result = { ok: true, descriptors };
if (!validatePluginsUiDescriptorsResult(result)) {
log.warn("invalid plugins.uiDescriptors result", {
errors: validatePluginsUiDescriptorsResult.errors,
});
respond(
false,
undefined,
errorShape(
ErrorCodes.UNAVAILABLE,
`invalid plugins.uiDescriptors result: ${formatValidationErrors(validatePluginsUiDescriptorsResult.errors)}`,
),
);
return;
}
respond(true, result, undefined);
},
"plugins.sessionAction": async ({ params, client, respond }) => {
if (!validatePluginsSessionActionParams(params)) {

View File

@@ -7,11 +7,13 @@ import {
} from "openclaw/plugin-sdk/plugin-test-contracts";
import { afterEach, describe, expect, it } from "vitest";
import {
validatePluginsUiDescriptorsResult,
validatePluginsUiDescriptorsParams,
validateSessionsPluginPatchParams,
} from "../../../packages/gateway-protocol/src/index.js";
import { loadSessionStore, updateSessionStore, type SessionEntry } from "../../config/sessions.js";
import { APPROVALS_SCOPE, READ_SCOPE, WRITE_SCOPE } from "../../gateway/operator-scopes.js";
import { pluginHostHookHandlers } from "../../gateway/server-methods/plugin-host-hooks.js";
import { buildGatewaySessionRow } from "../../gateway/session-utils.js";
import { withTempConfig } from "../../gateway/test-temp-config.js";
import { emitAgentEvent, resetAgentEventsForTest } from "../../infra/agent-events.js";
@@ -1892,6 +1894,84 @@ describe("host-hook fixture plugin contract", () => {
).toBe(false);
expect(validatePluginsUiDescriptorsParams({})).toBe(true);
expect(validatePluginsUiDescriptorsParams({ pluginId: "host-hook-fixture" })).toBe(false);
expect(
validatePluginsUiDescriptorsResult({
ok: true,
descriptors: [
{
id: "approval-panel",
pluginId: "host-hook-fixture",
surface: "session",
label: "Approval panel",
},
],
}),
).toBe(true);
expect(
validatePluginsUiDescriptorsResult({
ok: true,
descriptors: [
{
id: "approval-panel",
pluginId: "host-hook-fixture",
surface: "session",
label: "Approval panel",
leakedRegistryField: true,
},
],
}),
).toBe(false);
});
it("projects plugin UI descriptors through the strict gateway result shape", () => {
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({
id: "host-hook-fixture",
name: "Host Hook Fixture",
}),
register(api) {
api.registerControlUiDescriptor({
id: "approval-panel",
surface: "session",
label: "Approval panel",
});
},
});
const descriptorEntry = registry.registry.controlUiDescriptors?.[0];
if (!descriptorEntry) {
throw new Error("expected control UI descriptor registration");
}
Object.assign(descriptorEntry.descriptor, { leakedRegistryField: true });
setActivePluginRegistry(registry.registry);
const calls: Array<[boolean, unknown, unknown]> = [];
void pluginHostHookHandlers["plugins.uiDescriptors"]({
params: {},
respond: (ok: boolean, payload: unknown, error: unknown) => {
calls.push([ok, payload, error]);
},
} as never);
expect(calls).toHaveLength(1);
const [ok, payload, error] = calls[0] ?? [];
expect(ok).toBe(true);
expect(error).toBeUndefined();
expect(validatePluginsUiDescriptorsResult(payload)).toBe(true);
expect(payload).toEqual({
ok: true,
descriptors: [
{
id: "approval-panel",
pluginId: "host-hook-fixture",
pluginName: "Host Hook Fixture",
surface: "session",
label: "Approval panel",
},
],
});
});
it("enforces command requiredScopes for gateway clients and command owners", async () => {