diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index ee189f400679..2e98ebac84cf 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -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( PluginsUiDescriptorsParamsSchema, ); +export const validatePluginsUiDescriptorsResult = lazyCompile( + PluginsUiDescriptorsResultSchema, +); export const validatePluginsSessionActionParams = lazyCompile( PluginsSessionActionParamsSchema, ); @@ -1133,6 +1138,7 @@ export { PluginsSessionActionParamsSchema, PluginsSessionActionResultSchema, PluginsUiDescriptorsParamsSchema, + PluginsUiDescriptorsResultSchema, ModelsListParamsSchema, SkillsStatusParamsSchema, ToolsCatalogParamsSchema, diff --git a/src/config/sessions/session-accessor.test.ts b/src/config/sessions/session-accessor.test.ts index ef7960f44693..4e2972a37d3c 100644 --- a/src/config/sessions/session-accessor.test.ts +++ b/src/config/sessions/session-accessor.test.ts @@ -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 () => { diff --git a/src/config/sessions/session-accessor.ts b/src/config/sessions/session-accessor.ts index 5865c5ad4835..f5b760fab34e 100644 --- a/src/config/sessions/session-accessor.ts +++ b/src/config/sessions/session-accessor.ts @@ -1148,6 +1148,7 @@ async function replaceTranscriptForManualCompact( throw err; } emitSessionTranscriptUpdate({ sessionFile: archived }); + emitSessionTranscriptUpdate({ sessionFile: filePath }); return archived; } diff --git a/src/gateway/server-methods/plugin-host-hooks.ts b/src/gateway/server-methods/plugin-host-hooks.ts index e4828d34b6b5..36bd52c3641b 100644 --- a/src/gateway/server-methods/plugin-host-hooks.ts +++ b/src/gateway/server-methods/plugin-host-hooks.ts @@ -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 = { + 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)) { diff --git a/src/plugins/contracts/host-hooks.contract.test.ts b/src/plugins/contracts/host-hooks.contract.test.ts index b59f02dfef06..8b192000254b 100644 --- a/src/plugins/contracts/host-hooks.contract.test.ts +++ b/src/plugins/contracts/host-hooks.contract.test.ts @@ -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 () => {