diff --git a/CHANGELOG.md b/CHANGELOG.md index 44e36c45b29..38c74d94e26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ Docs: https://docs.openclaw.ai - Plugins/Google Chat: log webhook auth rejection reasons only after all candidates fail, and warn when add-on `appPrincipal` values do not match configuration. Fixes #71078. (#71145) Thanks @luyao618. - Models/configure: preserve the existing default model when provider auth is re-run from configure while keeping explicit default-setting commands authoritative. Fixes #70696. (#70793) Thanks @Sathvik-1007. +- Config/plugins: accept `plugins.entries.*.hooks.allowConversationAccess` in validation, generated schema metadata, and plugin policy inspection so trusted external plugins can enable conversation-access hooks such as `agent_end` without local schema patches. Fixes #71215. (#71221) Thanks @BillChirico. - Codex harness/models: keep legacy `codex/*` harness shorthand out of model picker and `/models` choice surfaces while migrating primary legacy refs to canonical `openai/*` plus explicit Codex harness config. (#71193) Thanks @vincentkoc. - Plugins/runtime deps: respect explicit plugin and channel disablement when repairing bundled runtime dependencies, so doctor and health checks no longer install deps for disabled configured channels. - Diagnostics: harden tool and model diagnostic events against hostile errors, blocking listeners, and unsafe stability reason fields. Thanks @vincentkoc. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index dc8998e3635..704a35ea5dc 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -2894c80bc234b33f14a0ddb09e6f42368da4ca23e0e7faceb13aba52239c1a76 config-baseline.json +bfae9b7760c3372f48d073da40059b0faa43c33f643b4aac3a942932a32df9eb config-baseline.json c8ff25fcdd2389d5fd88f8ba188d77c21f58b56765b555eecf3b37437f743d50 config-baseline.core.json 22d7cd6d8279146b2d79c9531a55b80b52a2c99c81338c508104729154fdd02d config-baseline.channel.json -5ce9062d0ab7f9447f149fc0770571068b4c4c89e0fb80ae6ef7b3f2a146c8b3 config-baseline.plugin.json +5bace1f246d5462dcf00ec7d4f378350bc7b6d01141609d704dc8c2e03e2230a config-baseline.plugin.json diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 0cd85bda65b..13165ff8e66 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -118,6 +118,7 @@ provider / base-URL setup moved to a dedicated page — see - `plugins.entries..apiKey`: plugin-level API key convenience field (when supported by the plugin). - `plugins.entries..env`: plugin-scoped env var map. - `plugins.entries..hooks.allowPromptInjection`: when `false`, core blocks `before_prompt_build` and ignores prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride`. Applies to native plugin hooks and supported bundle-provided hook directories. +- `plugins.entries..hooks.allowConversationAccess`: when `true`, trusted non-bundled plugins may read raw conversation content from typed hooks such as `llm_input`, `llm_output`, and `agent_end`. - `plugins.entries..subagent.allowModelOverride`: explicitly trust this plugin to request per-run `provider` and `model` overrides for background subagent runs. - `plugins.entries..subagent.allowedModels`: optional allowlist of canonical `provider/model` targets for trusted subagent overrides. Use `"*"` only when you intentionally want to allow any model. - `plugins.entries..config`: plugin-defined config object (validated by native OpenClaw plugin schema when available). diff --git a/src/cli/config-cli.integration.test.ts b/src/cli/config-cli.integration.test.ts index 22656400643..88e44094800 100644 --- a/src/cli/config-cli.integration.test.ts +++ b/src/cli/config-cli.integration.test.ts @@ -110,6 +110,49 @@ async function withExecDryRunConfigHarness( } describe("config cli integration", () => { + it("accepts plugin hook conversation-access policy via config set", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-cli-plugin-hooks-")); + const configPath = path.join(tempDir, "openclaw.json"); + const envSnapshot = captureEnv(["OPENCLAW_CONFIG_PATH", "OPENCLAW_TEST_FAST"]); + try { + fs.writeFileSync( + configPath, + `${JSON.stringify( + { + gateway: { port: 18789 }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + process.env.OPENCLAW_TEST_FAST = "1"; + process.env.OPENCLAW_CONFIG_PATH = configPath; + clearConfigCache(); + clearRuntimeConfigSnapshot(); + + const runtime = createTestRuntime(); + await runConfigSet({ + path: "plugins.entries.openclaw-mem0.hooks.allowConversationAccess", + value: "true", + cliOptions: {}, + runtime: runtime.runtime, + }); + + expect(runtime.errors).toEqual([]); + const afterWrite = JSON5.parse(fs.readFileSync(configPath, "utf8")); + expect(afterWrite.plugins?.entries?.["openclaw-mem0"]?.hooks).toEqual({ + allowConversationAccess: true, + }); + } finally { + envSnapshot.restore(); + clearConfigCache(); + clearRuntimeConfigSnapshot(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("supports batch-file dry-run and then writes real config changes", async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-cli-int-")); const configPath = path.join(tempDir, "openclaw.json"); diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts index 4b3f24b0744..1c5ff1171d8 100644 --- a/src/cli/plugins-cli-test-helpers.ts +++ b/src/cli/plugins-cli-test-helpers.ts @@ -35,6 +35,7 @@ export const recordPluginInstall: UnknownMock = vi.fn(); export const clearPluginManifestRegistryCache: UnknownMock = vi.fn(); export const loadPluginManifestRegistry: UnknownMock = vi.fn(); export const buildPluginSnapshotReport: UnknownMock = vi.fn(); +export const buildPluginInspectReport: UnknownMock = vi.fn(); export const buildPluginDiagnosticsReport: UnknownMock = vi.fn(); export const buildPluginCompatibilityNotices: UnknownMock = vi.fn(); export const applyExclusiveSlotSelection: UnknownMock = vi.fn(); @@ -166,6 +167,16 @@ vi.mock("../plugins/status.js", () => ({ buildPluginSnapshotReport, ...args, )) as (typeof import("../plugins/status.js"))["buildPluginSnapshotReport"], + buildPluginInspectReport: (( + ...args: Parameters<(typeof import("../plugins/status.js"))["buildPluginInspectReport"]> + ) => + invokeMock< + Parameters<(typeof import("../plugins/status.js"))["buildPluginInspectReport"]>, + ReturnType<(typeof import("../plugins/status.js"))["buildPluginInspectReport"]> + >( + buildPluginInspectReport, + ...args, + )) as (typeof import("../plugins/status.js"))["buildPluginInspectReport"], buildPluginDiagnosticsReport: (( ...args: Parameters<(typeof import("../plugins/status.js"))["buildPluginDiagnosticsReport"]> ) => @@ -186,6 +197,7 @@ vi.mock("../plugins/status.js", () => ({ buildPluginCompatibilityNotices, ...args, )) as (typeof import("../plugins/status.js"))["buildPluginCompatibilityNotices"], + formatPluginCompatibilityNotice: (entry: { message: string }) => entry.message, })); vi.mock("../plugins/slots.js", async (importOriginal) => { @@ -377,6 +389,7 @@ export function resetPluginsCliTestState() { clearPluginManifestRegistryCache.mockReset(); loadPluginManifestRegistry.mockReset(); buildPluginSnapshotReport.mockReset(); + buildPluginInspectReport.mockReset(); buildPluginDiagnosticsReport.mockReset(); buildPluginCompatibilityNotices.mockReset(); applyExclusiveSlotSelection.mockReset(); diff --git a/src/cli/plugins-cli.list.test.ts b/src/cli/plugins-cli.list.test.ts index c0068ed1bdf..424c33bb09b 100644 --- a/src/cli/plugins-cli.list.test.ts +++ b/src/cli/plugins-cli.list.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { createPluginRecord } from "../plugins/status.test-helpers.js"; import { buildPluginDiagnosticsReport, + buildPluginInspectReport, buildPluginSnapshotReport, resetPluginsCliTestState, runPluginsCommand, @@ -64,4 +65,40 @@ describe("plugins cli list", () => { expect(buildPluginDiagnosticsReport).toHaveBeenCalledWith(); expect(runtimeLogs).toContain("No plugin issues detected."); }); + + it("shows conversation-access hook policy in inspect output", async () => { + buildPluginInspectReport.mockReturnValue({ + workspaceDir: "/workspace", + plugin: createPluginRecord({ id: "openclaw-mem0", name: "Mem0" }), + shape: "hook-only", + capabilityMode: "plain", + capabilityCount: 1, + capabilities: [], + typedHooks: [{ name: "agent_end" }], + customHooks: [], + tools: [], + commands: [], + cliCommands: [], + services: [], + gatewayDiscoveryServices: [], + gatewayMethods: [], + mcpServers: [], + lspServers: [], + httpRouteCount: 0, + bundleCapabilities: [], + diagnostics: [], + policy: { + allowConversationAccess: true, + allowedModels: [], + hasAllowedModelsConfig: false, + }, + usesLegacyBeforeAgentStart: false, + compatibility: [], + }); + + await runPluginsCommand(["plugins", "inspect", "openclaw-mem0"]); + + expect(runtimeLogs.join("\n")).toContain("Policy"); + expect(runtimeLogs.join("\n")).toContain("allowConversationAccess: true"); + }); }); diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 123558e9c5b..6ba9ec8293a 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -454,6 +454,9 @@ export function registerPluginsCli(program: Command) { if (typeof inspect.policy.allowPromptInjection === "boolean") { policyLines.push(`allowPromptInjection: ${inspect.policy.allowPromptInjection}`); } + if (typeof inspect.policy.allowConversationAccess === "boolean") { + policyLines.push(`allowConversationAccess: ${inspect.policy.allowConversationAccess}`); + } if (typeof inspect.policy.allowModelOverride === "boolean") { policyLines.push(`allowModelOverride: ${inspect.policy.allowModelOverride}`); } diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index fc8e08708ec..930827b2eca 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -148,7 +148,7 @@ describe("gateway.controlUi.allowExternalEmbedUrls", () => { }); }); -describe("plugins.entries.*.hooks.allowPromptInjection", () => { +describe("plugins.entries.*.hooks", () => { it("accepts boolean values", () => { const result = OpenClawSchema.safeParse({ plugins: { @@ -156,6 +156,7 @@ describe("plugins.entries.*.hooks.allowPromptInjection", () => { "voice-call": { hooks: { allowPromptInjection: false, + allowConversationAccess: true, }, }, }, @@ -171,6 +172,23 @@ describe("plugins.entries.*.hooks.allowPromptInjection", () => { "voice-call": { hooks: { allowPromptInjection: "no", + allowConversationAccess: true, + }, + }, + }, + }, + }); + expect(result.success).toBe(false); + }); + + it("rejects non-boolean conversation access values", () => { + const result = OpenClawSchema.safeParse({ + plugins: { + entries: { + "voice-call": { + hooks: { + allowPromptInjection: false, + allowConversationAccess: "yes", }, }, }, diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 1987aea45f9..251e40b6d02 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -22705,6 +22705,12 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { description: "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", }, + allowConversationAccess: { + type: "boolean", + title: "Allow Conversation Access Hooks", + description: + "Controls whether this plugin may read raw conversation content from typed hooks such as `llm_input`, `llm_output`, and `agent_end`. Non-bundled plugins must opt in explicitly.", + }, }, additionalProperties: false, title: "Plugin Hook Policy", @@ -27246,6 +27252,11 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { help: "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", tags: ["access"], }, + "plugins.entries.*.hooks.allowConversationAccess": { + label: "Allow Conversation Access Hooks", + help: "Controls whether this plugin may read raw conversation content from typed hooks such as `llm_input`, `llm_output`, and `agent_end`. Non-bundled plugins must opt in explicitly.", + tags: ["access"], + }, "plugins.entries.*.subagent": { label: "Plugin Subagent Policy", help: "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index 32af26df6b3..f3fa8d84d47 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -352,6 +352,7 @@ const TARGET_KEYS = [ "plugins.entries.*.enabled", "plugins.entries.*.hooks", "plugins.entries.*.hooks.allowPromptInjection", + "plugins.entries.*.hooks.allowConversationAccess", "plugins.entries.*.subagent", "plugins.entries.*.subagent.allowModelOverride", "plugins.entries.*.subagent.allowedModels", @@ -763,6 +764,11 @@ describe("config help copy quality", () => { expect(pluginPromptPolicy.includes("before_prompt_build")).toBe(true); expect(pluginPromptPolicy.includes("before_agent_start")).toBe(true); expect(pluginPromptPolicy.includes("modelOverride")).toBe(true); + + const pluginConversationPolicy = FIELD_HELP["plugins.entries.*.hooks.allowConversationAccess"]; + expect(pluginConversationPolicy.includes("llm_input")).toBe(true); + expect(pluginConversationPolicy.includes("llm_output")).toBe(true); + expect(pluginConversationPolicy.includes("agent_end")).toBe(true); }); it("documents auth/model root semantics and provider secret handling", () => { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 1a3201eb4cc..dcb4c5e3b9c 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1106,6 +1106,8 @@ export const FIELD_HELP: Record = { "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "plugins.entries.*.hooks.allowPromptInjection": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "plugins.entries.*.hooks.allowConversationAccess": + "Controls whether this plugin may read raw conversation content from typed hooks such as `llm_input`, `llm_output`, and `agent_end`. Non-bundled plugins must opt in explicitly.", "plugins.entries.*.subagent": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", "plugins.entries.*.subagent.allowModelOverride": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 5ac405de0e9..8ca3b04d596 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -837,6 +837,7 @@ export const FIELD_LABELS: Record = { "plugins.entries.*.enabled": "Plugin Enabled", "plugins.entries.*.hooks": "Plugin Hook Policy", "plugins.entries.*.hooks.allowPromptInjection": "Allow Prompt Injection Hooks", + "plugins.entries.*.hooks.allowConversationAccess": "Allow Conversation Access Hooks", "plugins.entries.*.subagent": "Plugin Subagent Policy", "plugins.entries.*.subagent.allowModelOverride": "Allow Plugin Subagent Model Override", "plugins.entries.*.subagent.allowedModels": "Plugin Subagent Allowed Models", diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index ce8c7a1d63c..58d246afadf 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -161,6 +161,7 @@ const PluginEntrySchema = z hooks: z .object({ allowPromptInjection: z.boolean().optional(), + allowConversationAccess: z.boolean().optional(), }) .strict() .optional(),