mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:20:43 +00:00
fix(config): allow plugin conversation access hook policy (#71221)
This commit is contained in:
committed by
Peter Steinberger
parent
ef9ca09b8e
commit
51dd4f288f
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -118,6 +118,7 @@ provider / base-URL setup moved to a dedicated page — see
|
||||
- `plugins.entries.<id>.apiKey`: plugin-level API key convenience field (when supported by the plugin).
|
||||
- `plugins.entries.<id>.env`: plugin-scoped env var map.
|
||||
- `plugins.entries.<id>.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.<id>.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.<id>.subagent.allowModelOverride`: explicitly trust this plugin to request per-run `provider` and `model` overrides for background subagent runs.
|
||||
- `plugins.entries.<id>.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.<id>.config`: plugin-defined config object (validated by native OpenClaw plugin schema when available).
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -1106,6 +1106,8 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"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":
|
||||
|
||||
@@ -837,6 +837,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"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",
|
||||
|
||||
@@ -161,6 +161,7 @@ const PluginEntrySchema = z
|
||||
hooks: z
|
||||
.object({
|
||||
allowPromptInjection: z.boolean().optional(),
|
||||
allowConversationAccess: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
|
||||
Reference in New Issue
Block a user