diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e4f44ba6fa..30993dbb23c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai - Gateway/startup: pass the plugin metadata snapshot from config validation into plugin bootstrap so startup reuses one manifest product instead of rebuilding plugin metadata. Thanks @shakkernerd. - Plugin SDK/testing: promote bundled plugin/provider/channel contract helpers to focused SDK test subpaths and retire the repo-only `test/helpers/plugins` TypeScript bridge. Thanks @vincentkoc. - Plugin SDK/testing: add a focused `plugin-sdk/plugin-test-api` helper subpath and move bundled plugin registration tests off the repo-only plugin API bridge. Thanks @vincentkoc. +- Plugin SDK: add generic host hooks for session state, next-turn context, trusted tool policy, UI descriptors, events, scheduler cleanup, and run-scoped plugin context. (#72287) Thanks @100yenadmin. - Plugin SDK/testing: expose provider catalog, wizard, registry, manifest, public-artifact, outbound, and TTS contract helpers through documented SDK testing seams so bundled plugin tests no longer import repo `src/**` internals. Thanks @vincentkoc. - Matrix: attach versioned structured approval metadata to pending approval messages so capable Matrix clients can render richer approval UI while body text and reaction fallback keep working. (#72432) Thanks @kakahu2015. diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index 95734e8e5b2..0c3304d864f 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -1881,6 +1881,58 @@ public struct SessionsPatchParams: Codable, Sendable { } } +public struct SessionsPluginPatchParams: Codable, Sendable { + public let key: String + public let pluginid: String + public let namespace: String + public let value: AnyCodable? + public let unset: Bool? + + public init( + key: String, + pluginid: String, + namespace: String, + value: AnyCodable?, + unset: Bool?) + { + self.key = key + self.pluginid = pluginid + self.namespace = namespace + self.value = value + self.unset = unset + } + + private enum CodingKeys: String, CodingKey { + case key + case pluginid = "pluginId" + case namespace + case value + case unset + } +} + +public struct SessionsPluginPatchResult: Codable, Sendable { + public let ok: Bool + public let key: String + public let value: AnyCodable? + + public init( + ok: Bool, + key: String, + value: AnyCodable?) + { + self.ok = ok + self.key = key + self.value = value + } + + private enum CodingKeys: String, CodingKey { + case ok + case key + case value + } +} + public struct SessionsResetParams: Codable, Sendable { public let key: String public let reason: AnyCodable? @@ -3292,6 +3344,8 @@ public struct ToolCatalogEntry: Codable, Sendable { public let source: AnyCodable public let pluginid: String? public let optional: Bool? + public let risk: AnyCodable? + public let tags: [String]? public let defaultprofiles: [AnyCodable] public init( @@ -3301,6 +3355,8 @@ public struct ToolCatalogEntry: Codable, Sendable { source: AnyCodable, pluginid: String?, optional: Bool?, + risk: AnyCodable?, + tags: [String]?, defaultprofiles: [AnyCodable]) { self.id = id @@ -3309,6 +3365,8 @@ public struct ToolCatalogEntry: Codable, Sendable { self.source = source self.pluginid = pluginid self.optional = optional + self.risk = risk + self.tags = tags self.defaultprofiles = defaultprofiles } @@ -3319,6 +3377,8 @@ public struct ToolCatalogEntry: Codable, Sendable { case source case pluginid = "pluginId" case optional + case risk + case tags case defaultprofiles = "defaultProfiles" } } @@ -3401,6 +3461,8 @@ public struct ToolsEffectiveEntry: Codable, Sendable { public let source: AnyCodable public let pluginid: String? public let channelid: String? + public let risk: AnyCodable? + public let tags: [String]? public init( id: String, @@ -3409,7 +3471,9 @@ public struct ToolsEffectiveEntry: Codable, Sendable { rawdescription: String, source: AnyCodable, pluginid: String?, - channelid: String?) + channelid: String?, + risk: AnyCodable?, + tags: [String]?) { self.id = id self.label = label @@ -3418,6 +3482,8 @@ public struct ToolsEffectiveEntry: Codable, Sendable { self.source = source self.pluginid = pluginid self.channelid = channelid + self.risk = risk + self.tags = tags } private enum CodingKeys: String, CodingKey { @@ -3428,6 +3494,8 @@ public struct ToolsEffectiveEntry: Codable, Sendable { case source case pluginid = "pluginId" case channelid = "channelId" + case risk + case tags } } @@ -4215,6 +4283,72 @@ public struct PluginApprovalResolveParams: Codable, Sendable { } } +public struct PluginControlUiDescriptor: Codable, Sendable { + public let id: String + public let pluginid: String + public let pluginname: String? + public let surface: AnyCodable + public let label: String + public let description: String? + public let placement: String? + public let schema: AnyCodable? + public let requiredscopes: [String]? + + public init( + id: String, + pluginid: String, + pluginname: String?, + surface: AnyCodable, + label: String, + description: String?, + placement: String?, + schema: AnyCodable?, + requiredscopes: [String]?) + { + self.id = id + self.pluginid = pluginid + self.pluginname = pluginname + self.surface = surface + self.label = label + self.description = description + self.placement = placement + self.schema = schema + self.requiredscopes = requiredscopes + } + + private enum CodingKeys: String, CodingKey { + case id + case pluginid = "pluginId" + case pluginname = "pluginName" + case surface + case label + case description + case placement + case schema + case requiredscopes = "requiredScopes" + } +} + +public struct PluginsUiDescriptorsParams: Codable, Sendable {} + +public struct PluginsUiDescriptorsResult: Codable, Sendable { + public let ok: Bool + public let descriptors: [PluginControlUiDescriptor] + + public init( + ok: Bool, + descriptors: [PluginControlUiDescriptor]) + { + self.ok = ok + self.descriptors = descriptors + } + + private enum CodingKeys: String, CodingKey { + case ok + case descriptors + } +} + public struct DevicePairListParams: Codable, Sendable {} public struct DevicePairApproveParams: Codable, Sendable { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 95734e8e5b2..0c3304d864f 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -1881,6 +1881,58 @@ public struct SessionsPatchParams: Codable, Sendable { } } +public struct SessionsPluginPatchParams: Codable, Sendable { + public let key: String + public let pluginid: String + public let namespace: String + public let value: AnyCodable? + public let unset: Bool? + + public init( + key: String, + pluginid: String, + namespace: String, + value: AnyCodable?, + unset: Bool?) + { + self.key = key + self.pluginid = pluginid + self.namespace = namespace + self.value = value + self.unset = unset + } + + private enum CodingKeys: String, CodingKey { + case key + case pluginid = "pluginId" + case namespace + case value + case unset + } +} + +public struct SessionsPluginPatchResult: Codable, Sendable { + public let ok: Bool + public let key: String + public let value: AnyCodable? + + public init( + ok: Bool, + key: String, + value: AnyCodable?) + { + self.ok = ok + self.key = key + self.value = value + } + + private enum CodingKeys: String, CodingKey { + case ok + case key + case value + } +} + public struct SessionsResetParams: Codable, Sendable { public let key: String public let reason: AnyCodable? @@ -3292,6 +3344,8 @@ public struct ToolCatalogEntry: Codable, Sendable { public let source: AnyCodable public let pluginid: String? public let optional: Bool? + public let risk: AnyCodable? + public let tags: [String]? public let defaultprofiles: [AnyCodable] public init( @@ -3301,6 +3355,8 @@ public struct ToolCatalogEntry: Codable, Sendable { source: AnyCodable, pluginid: String?, optional: Bool?, + risk: AnyCodable?, + tags: [String]?, defaultprofiles: [AnyCodable]) { self.id = id @@ -3309,6 +3365,8 @@ public struct ToolCatalogEntry: Codable, Sendable { self.source = source self.pluginid = pluginid self.optional = optional + self.risk = risk + self.tags = tags self.defaultprofiles = defaultprofiles } @@ -3319,6 +3377,8 @@ public struct ToolCatalogEntry: Codable, Sendable { case source case pluginid = "pluginId" case optional + case risk + case tags case defaultprofiles = "defaultProfiles" } } @@ -3401,6 +3461,8 @@ public struct ToolsEffectiveEntry: Codable, Sendable { public let source: AnyCodable public let pluginid: String? public let channelid: String? + public let risk: AnyCodable? + public let tags: [String]? public init( id: String, @@ -3409,7 +3471,9 @@ public struct ToolsEffectiveEntry: Codable, Sendable { rawdescription: String, source: AnyCodable, pluginid: String?, - channelid: String?) + channelid: String?, + risk: AnyCodable?, + tags: [String]?) { self.id = id self.label = label @@ -3418,6 +3482,8 @@ public struct ToolsEffectiveEntry: Codable, Sendable { self.source = source self.pluginid = pluginid self.channelid = channelid + self.risk = risk + self.tags = tags } private enum CodingKeys: String, CodingKey { @@ -3428,6 +3494,8 @@ public struct ToolsEffectiveEntry: Codable, Sendable { case source case pluginid = "pluginId" case channelid = "channelId" + case risk + case tags } } @@ -4215,6 +4283,72 @@ public struct PluginApprovalResolveParams: Codable, Sendable { } } +public struct PluginControlUiDescriptor: Codable, Sendable { + public let id: String + public let pluginid: String + public let pluginname: String? + public let surface: AnyCodable + public let label: String + public let description: String? + public let placement: String? + public let schema: AnyCodable? + public let requiredscopes: [String]? + + public init( + id: String, + pluginid: String, + pluginname: String?, + surface: AnyCodable, + label: String, + description: String?, + placement: String?, + schema: AnyCodable?, + requiredscopes: [String]?) + { + self.id = id + self.pluginid = pluginid + self.pluginname = pluginname + self.surface = surface + self.label = label + self.description = description + self.placement = placement + self.schema = schema + self.requiredscopes = requiredscopes + } + + private enum CodingKeys: String, CodingKey { + case id + case pluginid = "pluginId" + case pluginname = "pluginName" + case surface + case label + case description + case placement + case schema + case requiredscopes = "requiredScopes" + } +} + +public struct PluginsUiDescriptorsParams: Codable, Sendable {} + +public struct PluginsUiDescriptorsResult: Codable, Sendable { + public let ok: Bool + public let descriptors: [PluginControlUiDescriptor] + + public init( + ok: Bool, + descriptors: [PluginControlUiDescriptor]) + { + self.ok = ok + self.descriptors = descriptors + } + + private enum CodingKeys: String, CodingKey { + case ok + case descriptors + } +} + public struct DevicePairListParams: Codable, Sendable {} public struct DevicePairApproveParams: Codable, Sendable { diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index b56e15449f6..36290b9fd7b 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -d4aecd00eeb38998b1bf840f9448ebd3520f0c7e4a612465440ac906c6d28848 plugin-sdk-api-baseline.json -fa888992910838df4e0037461199b6a6b83f8d99de99ac9fb7021c86c41ef06f plugin-sdk-api-baseline.jsonl +bd1db6be3ae54ce8ba12599d5a0e57117670ecbb9ba2697d0c2fc79bfa83e3d1 plugin-sdk-api-baseline.json +41a47969072f07398326c26fca7d419e67a2a97fb495eae15769ded574c843c8 plugin-sdk-api-baseline.jsonl diff --git a/docs/plugins/hooks.md b/docs/plugins/hooks.md index 3597319928d..407c62b9399 100644 --- a/docs/plugins/hooks.md +++ b/docs/plugins/hooks.md @@ -66,11 +66,13 @@ observation-only. **Agent turn** - `before_model_resolve` — override provider or model before session messages load +- `agent_turn_prepare` — consume queued plugin turn injections and add same-turn context before prompt hooks - `before_prompt_build` — add dynamic context or system-prompt text before the model call - `before_agent_start` — compatibility-only combined phase; prefer the two hooks above - **`before_agent_reply`** — short-circuit the model turn with a synthetic reply or silence - **`before_agent_finalize`** — inspect the natural final answer and request one more model pass - `agent_end` — observe final messages, success state, and run duration +- `heartbeat_prompt_contribution` — add heartbeat-only context for background monitor and lifecycle plugins **Conversation observation** @@ -153,6 +155,13 @@ Rules: - `onResolution` receives the resolved approval decision — `allow-once`, `allow-always`, `deny`, `timeout`, or `cancelled`. +Bundled plugins that need host-level policy can register trusted tool policies +with `api.registerTrustedToolPolicy(...)`. These run before ordinary +`before_tool_call` hooks and before external plugin decisions. Use them only +for host-trusted gates such as workspace policy, budget enforcement, or +reserved workflow safety. External plugins should use normal `before_tool_call` +hooks. + ### Tool result persistence Tool results can include structured `details` for UI rendering, diagnostics, @@ -174,9 +183,15 @@ Use the phase-specific hooks for new plugins: - `before_model_resolve`: receives only the current prompt and attachment metadata. Return `providerOverride` or `modelOverride`. +- `agent_turn_prepare`: receives the current prompt, prepared session messages, + and any exactly-once queued injections drained for this session. Return + `prependContext` or `appendContext`. - `before_prompt_build`: receives the current prompt and session messages. - Return `prependContext`, `systemPrompt`, `prependSystemContext`, or - `appendSystemContext`. + Return `prependContext`, `appendContext`, `systemPrompt`, + `prependSystemContext`, or `appendSystemContext`. +- `heartbeat_prompt_contribution`: runs only for heartbeat turns and returns + `prependContext` or `appendContext`. It is intended for background monitors + that need to summarize current state without changing user-initiated turns. `before_agent_start` remains for compatibility. Prefer the explicit hooks above so your plugin does not depend on a legacy combined phase. @@ -219,8 +234,31 @@ Non-bundled plugins that need `llm_input`, `llm_output`, } ``` -Prompt-mutating hooks can be disabled per plugin with -`plugins.entries..hooks.allowPromptInjection=false`. +Prompt-mutating hooks and durable next-turn injections can be disabled per plugin +with `plugins.entries..hooks.allowPromptInjection=false`. + +### Session extensions and next-turn injections + +Workflow plugins can persist small JSON-compatible session state with +`api.registerSessionExtension(...)` and update it through the Gateway +`sessions.pluginPatch` method. Session rows project registered extension state +through `pluginExtensions`, letting Control UI and other clients render +plugin-owned status without learning plugin internals. + +Use `api.enqueueNextTurnInjection(...)` when a plugin needs durable context to +reach the next model turn exactly once. OpenClaw drains queued injections before +prompt hooks, drops expired injections, and deduplicates by `idempotencyKey` +per plugin. This is the right seam for approval resumes, policy summaries, +background monitor deltas, and command continuations that should be visible to +the model on the next turn but should not become permanent system prompt text. + +Cleanup semantics are part of the contract. Session extension cleanup and +runtime lifecycle cleanup callbacks receive `reset`, `delete`, `disable`, or +`restart`. The host removes the owning plugin's persistent session extension +state and pending next-turn injections for reset/delete/disable; restart keeps +durable session state while cleanup callbacks let plugins release scheduler +jobs, run context, and other out-of-band resources for the old runtime +generation. ## Message hooks diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index 619adf93f8b..714d6f268fd 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -113,6 +113,49 @@ provider- or plugin-specific policy to core prompt builders. | `api.registerMemoryPromptSupplement(builder)` | Additive memory-adjacent prompt section | | `api.registerMemoryCorpusSupplement(adapter)` | Additive memory search/read corpus | +### Host hooks for workflow plugins + +Host hooks are the SDK seams for plugins that need to participate in the host +lifecycle rather than only adding a provider, channel, or tool. They are +generic contracts; Plan Mode can use them, but so can approval workflows, +workspace policy gates, background monitors, setup wizards, and UI companion +plugins. + +| Method | Contract it owns | +| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------- | +| `api.registerSessionExtension(...)` | Plugin-owned, JSON-compatible session state projected through Gateway sessions | +| `api.enqueueNextTurnInjection(...)` | Durable exactly-once context injected into the next agent turn for one session | +| `api.registerTrustedToolPolicy(...)` | Bundled/trusted pre-plugin tool policy that can block or rewrite tool params | +| `api.registerToolMetadata(...)` | Tool catalog display metadata without changing the tool implementation | +| `api.registerCommand(...)` | Scoped plugin commands; command results can set `continueAgent: true` | +| `api.registerControlUiDescriptor(...)` | Control UI contribution descriptors for session, tool, run, or settings surfaces | +| `api.registerRuntimeLifecycle(...)` | Cleanup callbacks for plugin-owned runtime resources on reset/delete/reload paths | +| `api.registerAgentEventSubscription(...)` | Sanitized event subscriptions for workflow state and monitors | +| `api.setRunContext(...)` / `getRunContext(...)` / `clearRunContext(...)` | Per-run plugin scratch state cleared on terminal run lifecycle | +| `api.registerSessionSchedulerJob(...)` | Plugin-owned session scheduler job records with deterministic cleanup | + +The contracts intentionally split authority: + +- External plugins can own session extensions, UI descriptors, commands, tool + metadata, next-turn injections, and normal hooks. +- Trusted tool policies run before ordinary `before_tool_call` hooks and are + bundled-only because they participate in host safety policy. +- Reserved command ownership is bundled-only. External plugins should use their + own command names or aliases. +- `allowPromptInjection=false` disables prompt-mutating hooks including + `agent_turn_prepare`, `before_prompt_build`, `heartbeat_prompt_contribution`, + prompt fields from legacy `before_agent_start`, and + `enqueueNextTurnInjection`. + +Examples of non-Plan consumers: + +| Plugin archetype | Hooks used | +| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| Approval workflow | Session extension, command continuation, next-turn injection, UI descriptor | +| Budget/workspace policy gate | Trusted tool policy, tool metadata, session projection | +| Background lifecycle monitor | Runtime lifecycle cleanup, agent event subscription, session scheduler ownership/cleanup, heartbeat prompt contribution, UI descriptor | +| Setup or onboarding wizard | Session extension, scoped commands, Control UI descriptor | + Reserved core admin namespaces (`config.*`, `exec.approvals.*`, `wizard.*`, `update.*`) always stay `operator.admin`, even if a plugin tries to assign a diff --git a/extensions/codex/src/app-server/dynamic-tools.test.ts b/extensions/codex/src/app-server/dynamic-tools.test.ts index 7838f847863..5bd12e066e2 100644 --- a/extensions/codex/src/app-server/dynamic-tools.test.ts +++ b/extensions/codex/src/app-server/dynamic-tools.test.ts @@ -518,7 +518,7 @@ describe("createCodexDynamicToolBridge", () => { }); expect(result).toEqual({ - success: false, + success: true, contentItems: [{ type: "inputText", text: "blocked by policy" }], }); expect(execute).not.toHaveBeenCalled(); @@ -534,7 +534,14 @@ describe("createCodexDynamicToolBridge", () => { provider: "telegram", to: "chat-1", }, - error: "blocked by policy", + result: expect.objectContaining({ + content: [{ type: "text", text: "blocked by policy" }], + details: { + status: "blocked", + deniedReason: "plugin-before-tool-call", + reason: "blocked by policy", + }, + }), }), expect.objectContaining({ runId: "run-blocked", diff --git a/extensions/codex/src/app-server/openclaw-owned-tool-runtime-contract.test.ts b/extensions/codex/src/app-server/openclaw-owned-tool-runtime-contract.test.ts index ed5ddcc6f88..9af937eba8b 100644 --- a/extensions/codex/src/app-server/openclaw-owned-tool-runtime-contract.test.ts +++ b/extensions/codex/src/app-server/openclaw-owned-tool-runtime-contract.test.ts @@ -188,7 +188,7 @@ describe("OpenClaw-owned tool runtime contract — Codex app-server adapter", () }); expect(result).toEqual({ - success: false, + success: true, contentItems: [{ type: "inputText", text: "blocked by policy" }], }); expect(execute).not.toHaveBeenCalled(); @@ -204,7 +204,14 @@ describe("OpenClaw-owned tool runtime contract — Codex app-server adapter", () provider: "telegram", to: "chat-1", }, - error: "blocked by policy", + result: expect.objectContaining({ + content: [{ type: "text", text: "blocked by policy" }], + details: { + status: "blocked", + deniedReason: "plugin-before-tool-call", + reason: "blocked by policy", + }, + }), }), expect.objectContaining({ runId: "run-blocked", diff --git a/extensions/diagnostics-otel/src/service.ts b/extensions/diagnostics-otel/src/service.ts index fdf0e026565..b8f67b54d6e 100644 --- a/extensions/diagnostics-otel/src/service.ts +++ b/extensions/diagnostics-otel/src/service.ts @@ -1888,7 +1888,11 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { evt: Extract< DiagnosticEventPayload, { - type: "tool.execution.started" | "tool.execution.completed" | "tool.execution.error"; + type: + | "tool.execution.started" + | "tool.execution.completed" + | "tool.execution.error" + | "tool.execution.blocked"; } >, ): Record => ({ @@ -1985,6 +1989,27 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { span.end(evt.ts); }; + const recordToolExecutionBlocked = ( + evt: Extract, + metadata: DiagnosticEventMetadata, + ) => { + if (!tracesEnabled) { + return; + } + const spanAttrs: Record = { + ...toolExecutionBaseAttrs(evt), + "openclaw.outcome": "blocked", + "openclaw.deniedReason": lowCardinalityAttr(evt.deniedReason, "other"), + }; + addRunAttrs(spanAttrs, evt); + const span = spanWithDuration("openclaw.tool.execution", spanAttrs, 0, { + parentContext: activeTrustedParentContext(evt, metadata), + endTimeMs: evt.ts, + }); + setSpanAttrs(span, spanAttrs); + span.end(evt.ts); + }; + const recordExecProcessCompleted = ( evt: Extract, ) => { @@ -2141,6 +2166,9 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { case "tool.execution.error": recordToolExecutionError(evt, metadata); return; + case "tool.execution.blocked": + recordToolExecutionBlocked(evt, metadata); + return; case "exec.process.completed": recordExecProcessCompleted(evt); return; diff --git a/extensions/qa-lab/runtime-api.ts b/extensions/qa-lab/runtime-api.ts index 8f2cff9b7da..fd5e39eb35e 100644 --- a/extensions/qa-lab/runtime-api.ts +++ b/extensions/qa-lab/runtime-api.ts @@ -1,6 +1,7 @@ export { buildQaTarget, callGatewayFromCli, + type Command, createQaBusThread, defaultQaRuntimeModelForMode, definePluginEntry, @@ -36,6 +37,5 @@ export { searchQaBusMessages, sendQaBusMessage, setQaChannelRuntime, - type Command, } from "./src/runtime-api.js"; export { startQaLiveLaneGateway } from "./src/live-transports/shared/live-gateway.runtime.js"; diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index 23d4e81a780..5f8c155ea5a 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -280,6 +280,54 @@ describe("shouldSkipLocalCliCredentialEpoch", () => { } }); + it("applies agent_turn_prepare-only context on the CLI path", async () => { + const { dir, sessionFile } = createSessionFile(); + try { + const hookRunner = { + hasHooks: vi.fn((hookName: string) => hookName === "agent_turn_prepare"), + runAgentTurnPrepare: vi.fn(async () => ({ + prependContext: "turn prepend", + appendContext: "turn append", + })), + runBeforePromptBuild: vi.fn(), + runBeforeAgentStart: vi.fn(), + }; + mockGetGlobalHookRunner.mockReturnValue(hookRunner as never); + + const context = await prepareCliRunContext({ + sessionId: "session-test", + sessionKey: "agent:main:test", + agentId: "main", + trigger: "user", + sessionFile, + workspaceDir: dir, + prompt: "latest ask", + provider: "test-cli", + model: "test-model", + timeoutMs: 1_000, + runId: "run-test-turn-prepare", + config: createCliBackendConfig(), + }); + + expect(context.params.prompt).toBe("turn prepend\n\nlatest ask\n\nturn append"); + expect(hookRunner.runAgentTurnPrepare).toHaveBeenCalledWith( + { + prompt: "latest ask", + messages: [], + queuedInjections: [], + }, + expect.objectContaining({ + runId: "run-test-turn-prepare", + sessionKey: "agent:main:test", + }), + ); + expect(hookRunner.runBeforePromptBuild).not.toHaveBeenCalled(); + expect(hookRunner.runBeforeAgentStart).not.toHaveBeenCalled(); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + it("merges before_prompt_build and legacy before_agent_start hook context for CLI preparation", async () => { const { dir, sessionFile } = createSessionFile(); try { diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 7f3d70d3f4d..a78b6078169 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -1,3 +1,4 @@ +import { getRuntimeConfig } from "../../config/config.js"; import { ensureMcpLoopbackServer } from "../../gateway/mcp-http.js"; import { createMcpLoopbackServerConfig, @@ -325,45 +326,47 @@ export async function prepareCliRunContext( let systemPrompt = transformedSystemPrompt; let preparedPrompt = params.prompt; const hookRunner = getGlobalHookRunner(); - if (hookRunner?.hasHooks("before_prompt_build") || hookRunner?.hasHooks("before_agent_start")) { - try { - const hookResult = await resolvePromptBuildHookResult({ - prompt: params.prompt, - messages: loadOpenClawHistoryMessages(), - hookCtx: { - runId: params.runId, - agentId: sessionAgentId, - sessionKey: params.sessionKey, - sessionId: params.sessionId, - workspaceDir, - modelProviderId: params.provider, - modelId, - messageProvider: params.messageProvider, - trigger: params.trigger, - channelId: params.messageChannel ?? params.messageProvider, - }, - hookRunner, - }); - if (hookResult.prependContext) { - preparedPrompt = `${hookResult.prependContext}\n\n${preparedPrompt}`; - } - const hookSystemPrompt = hookResult.systemPrompt?.trim(); - if (hookSystemPrompt) { - systemPrompt = hookSystemPrompt; - } - systemPrompt = - composeSystemPromptWithHookContext({ - baseSystemPrompt: systemPrompt, - prependSystemContext: resolveAttemptPrependSystemContext({ - sessionKey: params.sessionKey, - trigger: params.trigger, - hookPrependSystemContext: hookResult.prependSystemContext, - }), - appendSystemContext: hookResult.appendSystemContext, - }) ?? systemPrompt; - } catch (error) { - cliBackendLog.warn(`cli prompt-build hook preparation failed: ${String(error)}`); + try { + const hookResult = await resolvePromptBuildHookResult({ + config: params.config ?? getRuntimeConfig(), + prompt: params.prompt, + messages: loadOpenClawHistoryMessages(), + hookCtx: { + runId: params.runId, + agentId: sessionAgentId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + workspaceDir, + modelProviderId: params.provider, + modelId, + messageProvider: params.messageProvider, + trigger: params.trigger, + channelId: params.messageChannel ?? params.messageProvider, + }, + hookRunner, + }); + if (hookResult.prependContext) { + preparedPrompt = `${hookResult.prependContext}\n\n${preparedPrompt}`; } + if (hookResult.appendContext) { + preparedPrompt = `${preparedPrompt}\n\n${hookResult.appendContext}`; + } + const hookSystemPrompt = hookResult.systemPrompt?.trim(); + if (hookSystemPrompt) { + systemPrompt = hookSystemPrompt; + } + systemPrompt = + composeSystemPromptWithHookContext({ + baseSystemPrompt: systemPrompt, + prependSystemContext: resolveAttemptPrependSystemContext({ + sessionKey: params.sessionKey, + trigger: params.trigger, + hookPrependSystemContext: hookResult.prependSystemContext, + }), + appendSystemContext: hookResult.appendSystemContext, + }) ?? systemPrompt; + } catch (error) { + cliBackendLog.warn(`cli prompt-build hook preparation failed: ${String(error)}`); } const openClawHistoryPrompt = reusableCliSession.sessionId ? undefined diff --git a/src/agents/openclaw-owned-tool-runtime-contract.test.ts b/src/agents/openclaw-owned-tool-runtime-contract.test.ts index 9c54586fbab..dd24a0e1535 100644 --- a/src/agents/openclaw-owned-tool-runtime-contract.test.ts +++ b/src/agents/openclaw-owned-tool-runtime-contract.test.ts @@ -352,8 +352,9 @@ describe("OpenClaw-owned tool runtime contract — Pi adapter", () => { expect(result).toEqual( expect.objectContaining({ details: expect.objectContaining({ - status: "error", - error: "blocked by policy", + status: "blocked", + deniedReason: "plugin-before-tool-call", + reason: "blocked by policy", }), }), ); @@ -375,6 +376,14 @@ describe("OpenClaw-owned tool runtime contract — Pi adapter", () => { toolName: "message", toolCallId, params: originalParams, + result: expect.objectContaining({ + content: [{ type: "text", text: "blocked by policy" }], + details: { + status: "blocked", + deniedReason: "plugin-before-tool-call", + reason: "blocked by policy", + }, + }), error: "blocked by policy", }), expect.objectContaining({ diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 77fc6e6f404..526c5e65b84 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -90,6 +90,7 @@ import { log } from "./logger.js"; import { resolveModelAsync } from "./model.js"; import { createEmbeddedRunReplayState, observeReplayMetadata } from "./replay-state.js"; import { handleAssistantFailover } from "./run/assistant-failover.js"; +import { forgetPromptBuildDrainCacheForRun } from "./run/attempt.prompt-helpers.js"; import { createEmbeddedRunAuthController } from "./run/auth-controller.js"; import { resolveAuthProfileFailureReason } from "./run/auth-profile-failure-policy.js"; import { runEmbeddedAttemptWithBackend } from "./run/backend.js"; @@ -2432,6 +2433,7 @@ export async function runEmbeddedPiAgent( }; } } finally { + forgetPromptBuildDrainCacheForRun(params.runId); await contextEngine.dispose?.(); stopRuntimeAuthRefreshTimer(); if (params.cleanupBundleMcpOnRunEnd === true) { diff --git a/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.test.ts b/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.test.ts index 55c2556d416..73f9a7c36ca 100644 --- a/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.test.ts @@ -8,12 +8,19 @@ const videoGenerationTaskStatusMocks = vi.hoisted(() => ({ buildActiveVideoGenerationTaskPromptContextForSession: vi.fn(), })); +const hostHookStateMocks = vi.hoisted(() => ({ + drainPluginNextTurnInjectionContext: vi.fn(), +})); + vi.mock("../../music-generation-task-status.js", () => musicGenerationTaskStatusMocks); vi.mock("../../video-generation-task-status.js", () => videoGenerationTaskStatusMocks); +vi.mock("../../../plugins/host-hook-state.js", () => hostHookStateMocks); import { + forgetPromptBuildDrainCacheForRun, hasPromptSubmissionContent, resolveAttemptPrependSystemContext, + resolvePromptBuildHookResult, } from "./attempt.prompt-helpers.js"; describe("resolveAttemptPrependSystemContext", () => { @@ -104,3 +111,88 @@ describe("hasPromptSubmissionContent", () => { ).toBe(true); }); }); + +describe("resolvePromptBuildHookResult drain cache", () => { + it("drains plugin next-turn injections at most once per runId across retry attempts", async () => { + hostHookStateMocks.drainPluginNextTurnInjectionContext.mockReset(); + hostHookStateMocks.drainPluginNextTurnInjectionContext.mockResolvedValue({ + queuedInjections: [ + { + id: "inj-1", + pluginId: "demo", + text: "first attempt context", + placement: "prepend_context", + createdAt: 1, + }, + ], + prependContext: "first attempt context", + }); + forgetPromptBuildDrainCacheForRun("run-cache-test"); + + const hookCtx = { runId: "run-cache-test", sessionKey: "agent:main:main" }; + + const first = await resolvePromptBuildHookResult({ + config: {}, + prompt: "hi", + messages: [], + hookCtx, + }); + const second = await resolvePromptBuildHookResult({ + config: {}, + prompt: "hi", + messages: [], + hookCtx, + }); + + expect(hostHookStateMocks.drainPluginNextTurnInjectionContext).toHaveBeenCalledTimes(1); + expect(first.prependContext).toBe("first attempt context"); + expect(second.prependContext).toBe("first attempt context"); + + forgetPromptBuildDrainCacheForRun("run-cache-test"); + }); + + it("re-drains after the run-scoped cache is forgotten", async () => { + hostHookStateMocks.drainPluginNextTurnInjectionContext.mockReset(); + hostHookStateMocks.drainPluginNextTurnInjectionContext.mockResolvedValueOnce({ + queuedInjections: [], + prependContext: undefined, + }); + hostHookStateMocks.drainPluginNextTurnInjectionContext.mockResolvedValueOnce({ + queuedInjections: [], + prependContext: undefined, + }); + + const hookCtx = { runId: "run-evict-test", sessionKey: "agent:main:main" }; + + await resolvePromptBuildHookResult({ config: {}, prompt: "hi", messages: [], hookCtx }); + expect(hostHookStateMocks.drainPluginNextTurnInjectionContext).toHaveBeenCalledTimes(1); + + forgetPromptBuildDrainCacheForRun("run-evict-test"); + + await resolvePromptBuildHookResult({ config: {}, prompt: "hi", messages: [], hookCtx }); + expect(hostHookStateMocks.drainPluginNextTurnInjectionContext).toHaveBeenCalledTimes(2); + }); + + it("drains every call when no runId is provided (no caching key)", async () => { + hostHookStateMocks.drainPluginNextTurnInjectionContext.mockReset(); + hostHookStateMocks.drainPluginNextTurnInjectionContext.mockResolvedValue({ + queuedInjections: [], + prependContext: undefined, + }); + + await resolvePromptBuildHookResult({ + config: {}, + prompt: "hi", + messages: [], + hookCtx: { sessionKey: "agent:main:main" }, + }); + await resolvePromptBuildHookResult({ + config: {}, + prompt: "hi", + messages: [], + hookCtx: { sessionKey: "agent:main:main" }, + }); + + expect(hostHookStateMocks.drainPluginNextTurnInjectionContext).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts b/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts index ea7a1214aa2..5c34a99d013 100644 --- a/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts +++ b/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts @@ -3,7 +3,11 @@ import type { ContextEnginePromptCacheInfo, ContextEngineRuntimeContext, } from "../../../context-engine/types.js"; +import { drainPluginNextTurnInjectionContext } from "../../../plugins/host-hook-state.js"; +import { buildPluginAgentTurnPrepareContext } from "../../../plugins/host-hooks.js"; import type { + PluginAgentTurnPrepareResult, + PluginNextTurnInjectionRecord, PluginHookAgentContext, PluginHookBeforeAgentStartResult, PluginHookBeforePromptBuildResult, @@ -22,7 +26,25 @@ import { shouldInjectHeartbeatPromptForTrigger } from "./trigger-policy.js"; import type { EmbeddedRunAttemptParams } from "./types.js"; export type PromptBuildHookRunner = { - hasHooks: (hookName: "before_prompt_build" | "before_agent_start") => boolean; + hasHooks: ( + hookName: + | "agent_turn_prepare" + | "heartbeat_prompt_contribution" + | "before_prompt_build" + | "before_agent_start", + ) => boolean; + runAgentTurnPrepare?: ( + event: { + prompt: string; + messages: unknown[]; + queuedInjections: PluginNextTurnInjectionRecord[]; + }, + ctx: PluginHookAgentContext, + ) => Promise; + runHeartbeatPromptContribution?: ( + event: { sessionKey?: string; agentId?: string; heartbeatName?: string }, + ctx: PluginHookAgentContext, + ) => Promise; runBeforePromptBuild: ( event: { prompt: string; messages: unknown[] }, ctx: PluginHookAgentContext, @@ -33,13 +55,95 @@ export type PromptBuildHookRunner = { ) => Promise; }; +// Cache drained next-turn injections by runId so retry attempts within the +// same run reuse the first-attempt drain rather than calling drain again +// (which destructively consumes from the session store and would return [] on +// retry, dropping injection context). The cache is bounded to keep memory flat +// across long-lived processes; entries are evicted FIFO once the cap is hit. +const PROMPT_BUILD_DRAIN_CACHE_MAX = 256; +const promptBuildDrainCache = new Map(); + +function rememberDrainedInjections( + runId: string, + injections: PluginNextTurnInjectionRecord[], +): void { + if (promptBuildDrainCache.has(runId)) { + promptBuildDrainCache.delete(runId); + } else if (promptBuildDrainCache.size >= PROMPT_BUILD_DRAIN_CACHE_MAX) { + const oldest = promptBuildDrainCache.keys().next().value; + if (oldest !== undefined) { + promptBuildDrainCache.delete(oldest); + } + } + promptBuildDrainCache.set(runId, injections); +} + +/** + * Releases the per-run drained-injection cache. Call when a run terminates so + * the cap stays headroom for active runs. + */ +export function forgetPromptBuildDrainCacheForRun(runId: string | undefined): void { + if (runId) { + promptBuildDrainCache.delete(runId); + } +} + export async function resolvePromptBuildHookResult(params: { + config: OpenClawConfig; prompt: string; messages: unknown[]; hookCtx: PluginHookAgentContext; hookRunner?: PromptBuildHookRunner | null; legacyBeforeAgentStartResult?: PluginHookBeforeAgentStartResult; }): Promise { + const runId = params.hookCtx.runId; + const cachedInjections = runId ? promptBuildDrainCache.get(runId) : undefined; + const queuedContext = cachedInjections + ? { + queuedInjections: cachedInjections, + ...buildPluginAgentTurnPrepareContext({ queuedInjections: cachedInjections }), + } + : await drainPluginNextTurnInjectionContext({ + cfg: params.config, + sessionKey: params.hookCtx.sessionKey, + }); + if (runId && !cachedInjections) { + rememberDrainedInjections(runId, queuedContext.queuedInjections); + } + const turnPrepareResult = + params.hookRunner?.runAgentTurnPrepare && params.hookRunner.hasHooks("agent_turn_prepare") + ? await params.hookRunner + .runAgentTurnPrepare( + { + prompt: params.prompt, + messages: params.messages, + queuedInjections: queuedContext.queuedInjections, + }, + params.hookCtx, + ) + .catch((hookErr: unknown) => { + log.warn(`agent_turn_prepare hook failed: ${String(hookErr)}`); + return undefined; + }) + : undefined; + const heartbeatContribution = + params.hookCtx.trigger === "heartbeat" && + params.hookRunner?.runHeartbeatPromptContribution && + params.hookRunner.hasHooks("heartbeat_prompt_contribution") + ? await params.hookRunner + .runHeartbeatPromptContribution( + { + sessionKey: params.hookCtx.sessionKey, + agentId: params.hookCtx.agentId, + heartbeatName: "heartbeat", + }, + params.hookCtx, + ) + .catch((hookErr: unknown) => { + log.warn(`heartbeat_prompt_contribution hook failed: ${String(hookErr)}`); + return undefined; + }) + : undefined; const promptBuildResult = params.hookRunner?.hasHooks("before_prompt_build") ? await params.hookRunner .runBeforePromptBuild( @@ -75,9 +179,19 @@ export async function resolvePromptBuildHookResult(params: { return { systemPrompt: promptBuildResult?.systemPrompt ?? legacyResult?.systemPrompt, prependContext: joinPresentTextSegments([ + queuedContext.prependContext, + turnPrepareResult?.prependContext, + heartbeatContribution?.prependContext, promptBuildResult?.prependContext, legacyResult?.prependContext, ]), + appendContext: joinPresentTextSegments([ + queuedContext.appendContext, + turnPrepareResult?.appendContext, + heartbeatContribution?.appendContext, + promptBuildResult?.appendContext, + legacyResult?.appendContext, + ]), prependSystemContext: joinPresentTextSegments([ promptBuildResult?.prependSystemContext, legacyResult?.prependSystemContext, diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index 6a7e3463d9e..9c55c2d6f01 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -172,8 +172,13 @@ describe("resolvePromptBuildHookResult", () => { function createLegacyOnlyHookRunner() { return { hasHooks: vi.fn( - (hookName: "before_prompt_build" | "before_agent_start") => - hookName === "before_agent_start", + ( + hookName: + | "agent_turn_prepare" + | "heartbeat_prompt_contribution" + | "before_prompt_build" + | "before_agent_start", + ) => hookName === "before_agent_start", ), runBeforePromptBuild: vi.fn(async () => undefined), runBeforeAgentStart: vi.fn(async () => ({ prependContext: "from-hook" })), @@ -183,6 +188,7 @@ describe("resolvePromptBuildHookResult", () => { it("reuses precomputed legacy before_agent_start result without invoking hook again", async () => { const hookRunner = createLegacyOnlyHookRunner(); const result = await resolvePromptBuildHookResult({ + config: {}, prompt: "hello", messages: [], hookCtx: {}, @@ -193,6 +199,7 @@ describe("resolvePromptBuildHookResult", () => { expect(hookRunner.runBeforeAgentStart).not.toHaveBeenCalled(); expect(result).toEqual({ prependContext: "from-cache", + appendContext: undefined, systemPrompt: "legacy-system", prependSystemContext: undefined, appendSystemContext: undefined, @@ -203,6 +210,7 @@ describe("resolvePromptBuildHookResult", () => { const hookRunner = createLegacyOnlyHookRunner(); const messages = [{ role: "user", content: "ctx" }]; const result = await resolvePromptBuildHookResult({ + config: {}, prompt: "hello", messages, hookCtx: {}, @@ -219,17 +227,20 @@ describe("resolvePromptBuildHookResult", () => { hasHooks: vi.fn(() => true), runBeforePromptBuild: vi.fn(async () => ({ prependContext: "prompt context", + appendContext: "prompt append context", prependSystemContext: "prompt prepend", appendSystemContext: "prompt append", })), runBeforeAgentStart: vi.fn(async () => ({ prependContext: "legacy context", + appendContext: "legacy append context", prependSystemContext: "legacy prepend", appendSystemContext: "legacy append", })), }; const result = await resolvePromptBuildHookResult({ + config: {}, prompt: "hello", messages: [], hookCtx: {}, @@ -237,9 +248,47 @@ describe("resolvePromptBuildHookResult", () => { }); expect(result.prependContext).toBe("prompt context\n\nlegacy context"); + expect(result.appendContext).toBe("prompt append context\n\nlegacy append context"); expect(result.prependSystemContext).toBe("prompt prepend\n\nlegacy prepend"); expect(result.appendSystemContext).toBe("prompt append\n\nlegacy append"); }); + + it("applies heartbeat prompt contributions only during heartbeat turns", async () => { + const hookRunner = { + hasHooks: vi.fn((hookName: string) => hookName === "heartbeat_prompt_contribution"), + runHeartbeatPromptContribution: vi.fn(async () => ({ + prependContext: "heartbeat prepend", + appendContext: "heartbeat append", + })), + runBeforePromptBuild: vi.fn(async () => undefined), + runBeforeAgentStart: vi.fn(async () => undefined), + }; + + const heartbeatResult = await resolvePromptBuildHookResult({ + config: {}, + prompt: "hello", + messages: [], + hookCtx: { trigger: "heartbeat", sessionKey: "agent:main:main" }, + hookRunner, + }); + + expect(hookRunner.runHeartbeatPromptContribution).toHaveBeenCalledTimes(1); + expect(heartbeatResult.prependContext).toBe("heartbeat prepend"); + expect(heartbeatResult.appendContext).toBe("heartbeat append"); + + hookRunner.runHeartbeatPromptContribution.mockClear(); + const userResult = await resolvePromptBuildHookResult({ + config: {}, + prompt: "hello", + messages: [], + hookCtx: { trigger: "user", sessionKey: "agent:main:main" }, + hookRunner, + }); + + expect(hookRunner.runHeartbeatPromptContribution).not.toHaveBeenCalled(); + expect(userResult.prependContext).toBeUndefined(); + expect(userResult.appendContext).toBeUndefined(); + }); }); describe("composeSystemPromptWithHookContext", () => { diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 0a8d27a71f1..921f3f31874 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -10,6 +10,7 @@ import { import { isAcpRuntimeSpawnAvailable } from "../../../acp/runtime/availability.js"; import { filterHeartbeatPairs } from "../../../auto-reply/heartbeat-filter.js"; import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js"; +import { getRuntimeConfig } from "../../../config/config.js"; import { emitTrustedDiagnosticEvent } from "../../../infra/diagnostic-events.js"; import { createChildDiagnosticTraceContext, @@ -2257,6 +2258,7 @@ export async function runEmbeddedAttempt( const promptBuildMessages = pruneProcessedHistoryImages(activeSession.messages) ?? activeSession.messages; const hookResult = await resolvePromptBuildHookResult({ + config: params.config ?? getRuntimeConfig(), prompt: params.prompt, messages: promptBuildMessages, hookCtx, @@ -2270,6 +2272,12 @@ export async function runEmbeddedAttempt( `hooks: prepended context to prompt (${hookResult.prependContext.length} chars)`, ); } + if (hookResult?.appendContext) { + effectivePrompt = `${effectivePrompt}\n\n${hookResult.appendContext}`; + log.debug( + `hooks: appended context to prompt (${hookResult.appendContext.length} chars)`, + ); + } const legacySystemPrompt = normalizeOptionalString(hookResult?.systemPrompt) ?? ""; if (legacySystemPrompt) { applySystemPromptOverrideToSession(activeSession, legacySystemPrompt); diff --git a/src/agents/pi-tool-definition-adapter.after-tool-call.fires-once.test.ts b/src/agents/pi-tool-definition-adapter.after-tool-call.fires-once.test.ts index d095610668a..2e14ce9714d 100644 --- a/src/agents/pi-tool-definition-adapter.after-tool-call.fires-once.test.ts +++ b/src/agents/pi-tool-definition-adapter.after-tool-call.fires-once.test.ts @@ -20,6 +20,15 @@ const hookMocks = vi.hoisted(() => ({ })); const beforeToolCallMocks = vi.hoisted(() => ({ + BeforeToolCallBlockedError: class BeforeToolCallBlockedError extends Error { + reason: string; + + constructor(reason: string) { + super(reason); + this.name = "BeforeToolCallBlockedError"; + this.reason = reason; + } + }, consumeAdjustedParamsForToolCall: vi.fn((_: string): unknown => undefined), isToolWrappedWithBeforeToolCallHook: vi.fn(() => false), runBeforeToolCallHook: vi.fn(async ({ params }: { params: unknown }) => ({ @@ -88,7 +97,14 @@ async function loadFreshAfterToolCallModulesForTest() { emitAgentItemEvent: vi.fn(), })); vi.doMock("./pi-tools.before-tool-call.js", () => ({ + BeforeToolCallBlockedError: beforeToolCallMocks.BeforeToolCallBlockedError, + buildBlockedToolResult: ({ reason }: { reason: string }) => ({ + content: [{ type: "text", text: reason }], + details: { status: "blocked", deniedReason: "plugin-before-tool-call", reason }, + }), consumeAdjustedParamsForToolCall: beforeToolCallMocks.consumeAdjustedParamsForToolCall, + isBeforeToolCallBlockedError: (error: unknown) => + error instanceof beforeToolCallMocks.BeforeToolCallBlockedError, isToolWrappedWithBeforeToolCallHook: beforeToolCallMocks.isToolWrappedWithBeforeToolCallHook, runBeforeToolCallHook: beforeToolCallMocks.runBeforeToolCallHook, })); diff --git a/src/agents/pi-tool-definition-adapter.after-tool-call.test.ts b/src/agents/pi-tool-definition-adapter.after-tool-call.test.ts index 2e8cc45ea52..966d5a21078 100644 --- a/src/agents/pi-tool-definition-adapter.after-tool-call.test.ts +++ b/src/agents/pi-tool-definition-adapter.after-tool-call.test.ts @@ -8,6 +8,15 @@ const hookMocks = vi.hoisted(() => ({ hasHooks: vi.fn((_: string) => true), runAfterToolCall: vi.fn(async () => {}), }, + BeforeToolCallBlockedError: class BeforeToolCallBlockedError extends Error { + reason: string; + + constructor(reason: string) { + super(reason); + this.name = "BeforeToolCallBlockedError"; + this.reason = reason; + } + }, isToolWrappedWithBeforeToolCallHook: vi.fn(() => false), consumeAdjustedParamsForToolCall: vi.fn((_: string) => undefined as unknown), runBeforeToolCallHook: vi.fn(async ({ params }: { params: unknown }) => ({ @@ -21,7 +30,14 @@ vi.mock("../plugins/hook-runner-global.js", () => ({ })); vi.mock("./pi-tools.before-tool-call.js", () => ({ + BeforeToolCallBlockedError: hookMocks.BeforeToolCallBlockedError, + buildBlockedToolResult: ({ reason }: { reason: string }) => ({ + content: [{ type: "text", text: reason }], + details: { status: "blocked", deniedReason: "plugin-before-tool-call", reason }, + }), consumeAdjustedParamsForToolCall: hookMocks.consumeAdjustedParamsForToolCall, + isBeforeToolCallBlockedError: (error: unknown) => + error instanceof hookMocks.BeforeToolCallBlockedError, isToolWrappedWithBeforeToolCallHook: hookMocks.isToolWrappedWithBeforeToolCallHook, runBeforeToolCallHook: hookMocks.runBeforeToolCallHook, })); diff --git a/src/agents/pi-tool-definition-adapter.logging.test.ts b/src/agents/pi-tool-definition-adapter.logging.test.ts index 78ceb4c9fb2..463e4019cc3 100644 --- a/src/agents/pi-tool-definition-adapter.logging.test.ts +++ b/src/agents/pi-tool-definition-adapter.logging.test.ts @@ -13,6 +13,7 @@ vi.mock("../logger.js", () => ({ })); let toToolDefinitions: typeof import("./pi-tool-definition-adapter.js").toToolDefinitions; +let BeforeToolCallBlockedError: typeof import("./pi-tools.before-tool-call.js").BeforeToolCallBlockedError; let wrapToolParamValidation: typeof import("./pi-tools.params.js").wrapToolParamValidation; let REQUIRED_PARAM_GROUPS: typeof import("./pi-tools.params.js").REQUIRED_PARAM_GROUPS; let logError: typeof import("../logger.js").logError; @@ -25,6 +26,7 @@ const extensionContext = {} as Parameters[4]; describe("pi tool definition adapter logging", () => { beforeAll(async () => { ({ toToolDefinitions } = await import("./pi-tool-definition-adapter.js")); + ({ BeforeToolCallBlockedError } = await import("./pi-tools.before-tool-call.js")); ({ wrapToolParamValidation, REQUIRED_PARAM_GROUPS } = await import("./pi-tools.params.js")); ({ logError } = await import("../logger.js")); }); @@ -69,6 +71,46 @@ describe("pi tool definition adapter logging", () => { ); }); + it("does not log raw params for intentional before_tool_call blocks", async () => { + const baseTool = { + name: "bash", + label: "Bash", + description: "runs commands", + parameters: Type.Object({ + command: Type.String(), + }), + execute: async () => { + throw new BeforeToolCallBlockedError("blocked by policy"); + }, + } satisfies AgentTool; + const [def] = toToolDefinitions([baseTool]); + if (!def) { + throw new Error("missing tool definition"); + } + + const result = await def.execute( + "call-blocked-1", + { command: "secret-value" }, + undefined, + undefined, + extensionContext, + ); + + expect(result).toEqual( + expect.objectContaining({ + details: expect.objectContaining({ + status: "blocked", + deniedReason: "plugin-before-tool-call", + reason: "blocked by policy", + }), + }), + ); + expect(logError).not.toHaveBeenCalled(); + expect(mocks.logDebug).toHaveBeenCalledWith( + "tools: exec blocked by before_tool_call: blocked by policy", + ); + }); + it("accepts nested edits arrays for the current edit schema", async () => { const execute = vi.fn(async (_toolCallId: string, params: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(params) }], diff --git a/src/agents/pi-tool-definition-adapter.ts b/src/agents/pi-tool-definition-adapter.ts index 0aa4c609e2f..738cfdfda05 100644 --- a/src/agents/pi-tool-definition-adapter.ts +++ b/src/agents/pi-tool-definition-adapter.ts @@ -11,7 +11,9 @@ import { sanitizeForConsole } from "./console-sanitize.js"; import type { ClientToolDefinition } from "./pi-embedded-runner/run/params.js"; import type { HookContext } from "./pi-tools.before-tool-call.js"; import { + buildBlockedToolResult, isToolWrappedWithBeforeToolCallHook, + isBeforeToolCallBlockedError, runBeforeToolCallHook, } from "./pi-tools.before-tool-call.js"; import { normalizeToolName } from "./tool-policy.js"; @@ -234,6 +236,12 @@ export function toToolDefinitions(tools: AnyAgentTool[]): ToolDefinition[] { toolCallId, }); if (hookOutcome.blocked) { + if (hookOutcome.kind === "veto") { + return buildBlockedToolResult({ + reason: hookOutcome.reason, + deniedReason: hookOutcome.deniedReason, + }); + } throw new Error(hookOutcome.reason); } executeParams = hookOutcome.params; @@ -255,6 +263,12 @@ export function toToolDefinitions(tools: AnyAgentTool[]): ToolDefinition[] { if (name === "AbortError") { throw err; } + if (isBeforeToolCallBlockedError(err)) { + logDebug(`tools: ${normalizedName} blocked by before_tool_call: ${err.reason}`); + return buildBlockedToolResult({ + reason: err.reason, + }); + } const described = describeToolExecutionError(err); if (described.stack && described.stack !== described.message) { logDebug(`tools: ${normalizedName} failed stack:\n${described.stack}`); @@ -323,13 +337,20 @@ export function toClientToolDefinitions( parameters: func.parameters as ToolDefinition["parameters"], execute: async (...args: ToolExecuteArgs): Promise> => { const { toolCallId, params } = splitToolExecuteArgs(args); + const initialParamsRecord = coerceParamsRecord(params); const outcome = await runBeforeToolCallHook({ toolName: func.name, - params, + params: initialParamsRecord, toolCallId, ctx: hookContext, }); if (outcome.blocked) { + if (outcome.kind === "veto") { + return buildBlockedToolResult({ + reason: outcome.reason, + deniedReason: outcome.deniedReason, + }); + } throw new Error(outcome.reason); } const adjustedParams = outcome.params; diff --git a/src/agents/pi-tools.before-tool-call.e2e.test.ts b/src/agents/pi-tools.before-tool-call.e2e.test.ts index b9fc5d3659d..267bf9fa9f3 100644 --- a/src/agents/pi-tools.before-tool-call.e2e.test.ts +++ b/src/agents/pi-tools.before-tool-call.e2e.test.ts @@ -467,6 +467,43 @@ describe("before_tool_call loop detection behavior", () => { }); }); + it("emits blocked diagnostics without error severity for intentional hook vetoes", async () => { + hookRunner.hasHooks.mockReturnValue(true); + hookRunner.runBeforeToolCall.mockResolvedValue({ + block: true, + blockReason: "blocked by policy", + }); + const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "nope" }] }); + const tool = wrapToolWithBeforeToolCallHook({ name: "read", execute } as any, { + agentId: "main", + sessionKey: "session-key", + loopDetection: { enabled: false }, + }); + + await withToolExecutionEvents(async (emitted, flush) => { + const result = await tool.execute("tool-call-blocked", { path: "/tmp/file" }); + await flush(); + + expect(result).toEqual({ + content: [{ type: "text", text: "blocked by policy" }], + details: { + status: "blocked", + deniedReason: "plugin-before-tool-call", + reason: "blocked by policy", + }, + }); + expect(execute).not.toHaveBeenCalled(); + expect(emitted.map((evt) => evt.type)).toEqual(["tool.execution.blocked"]); + expect(emitted[0]).toMatchObject({ + type: "tool.execution.blocked", + toolName: "read", + toolCallId: "tool-call-blocked", + deniedReason: "plugin-before-tool-call", + reason: "blocked by policy", + }); + }); + }); + it("does not let hostile thrown values break diagnostic error emission", async () => { const hostileError = new Proxy( {}, diff --git a/src/agents/pi-tools.before-tool-call.embedded-mode.test.ts b/src/agents/pi-tools.before-tool-call.embedded-mode.test.ts index 36047c0a34d..1460cf55c27 100644 --- a/src/agents/pi-tools.before-tool-call.embedded-mode.test.ts +++ b/src/agents/pi-tools.before-tool-call.embedded-mode.test.ts @@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { setEmbeddedMode } from "../infra/embedded-mode.js"; import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; import type { HookRunner } from "../plugins/hooks.js"; +import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; import { PluginApprovalResolutions } from "../plugins/types.js"; import { runBeforeToolCallHook } from "./pi-tools.before-tool-call.js"; import { callGatewayTool } from "./tools/gateway.js"; @@ -34,10 +36,12 @@ describe("runBeforeToolCallHook — embedded mode approvals", () => { }; mockGetGlobalHookRunner.mockReturnValue(hookRunner as HookRunner); mockCallGatewayTool.mockReset(); + setActivePluginRegistry(createEmptyPluginRegistry()); }); afterEach(() => { setEmbeddedMode(false); + setActivePluginRegistry(createEmptyPluginRegistry()); }); it("blocks approval-required tools in embedded mode when no gateway approval route exists", async () => { @@ -64,7 +68,10 @@ describe("runBeforeToolCallHook — embedded mode approvals", () => { expect(result).toEqual({ blocked: true, + kind: "failure", + deniedReason: "plugin-approval", reason: "Plugin approval required (gateway unavailable)", + params: { command: "ls" }, }); expect(mockCallGatewayTool).toHaveBeenCalledWith( "plugin.approval.request", @@ -139,6 +146,87 @@ describe("runBeforeToolCallHook — embedded mode approvals", () => { } }); + it("routes trusted policy approval through the same approval gate as before_tool_call hooks", async () => { + setEmbeddedMode(true); + const registry = createEmptyPluginRegistry(); + registry.trustedToolPolicies = [ + { + pluginId: "trusted-policy", + pluginName: "Trusted Policy", + source: "test", + policy: { + id: "approval-policy", + description: "Approval policy", + evaluate: () => ({ + requireApproval: { + pluginId: "trusted-policy", + title: "Policy approval", + description: "Policy requested approval", + }, + }), + }, + }, + ]; + setActivePluginRegistry(registry); + (hookRunner.hasHooks as ReturnType).mockReturnValue(false); + mockCallGatewayTool.mockResolvedValueOnce({ + id: "approval-policy", + decision: PluginApprovalResolutions.ALLOW_ONCE, + }); + + const result = await runBeforeToolCallHook({ + toolName: "bash", + params: { command: "deploy" }, + toolCallId: "call-policy", + ctx: { agentId: "main", sessionKey: "main" }, + }); + + expect(result).toEqual({ blocked: false, params: { command: "deploy" } }); + expect(mockCallGatewayTool).toHaveBeenCalledWith( + "plugin.approval.request", + expect.any(Object), + expect.objectContaining({ + pluginId: "trusted-policy", + title: "Policy approval", + }), + { expectFinal: false }, + ); + expect(runBeforeToolCallMock).not.toHaveBeenCalled(); + }); + + it("preserves trusted policy params when before_tool_call hooks leave params unchanged", async () => { + const registry = createEmptyPluginRegistry(); + registry.trustedToolPolicies = [ + { + pluginId: "trusted-policy", + pluginName: "Trusted Policy", + source: "test", + policy: { + id: "param-policy", + description: "Param policy", + evaluate: () => ({ params: { command: "patched" } }), + }, + }, + ]; + setActivePluginRegistry(registry); + runBeforeToolCallMock.mockResolvedValue(undefined); + + const result = await runBeforeToolCallHook({ + toolName: "bash", + params: { command: "original", cwd: "/tmp" }, + toolCallId: "call-policy-params", + ctx: { agentId: "main", sessionKey: "main" }, + }); + + expect(result).toEqual({ blocked: false, params: { command: "patched" } }); + expect(runBeforeToolCallMock).toHaveBeenCalledWith( + expect.objectContaining({ + params: { command: "patched" }, + }), + expect.any(Object), + ); + }); + it("keeps original params after an approval allow decision without overrides", async () => { setEmbeddedMode(true); diff --git a/src/agents/pi-tools.before-tool-call.integration.e2e.test.ts b/src/agents/pi-tools.before-tool-call.integration.e2e.test.ts index 18f7f7b1503..24f82402444 100644 --- a/src/agents/pi-tools.before-tool-call.integration.e2e.test.ts +++ b/src/agents/pi-tools.before-tool-call.integration.e2e.test.ts @@ -125,7 +125,7 @@ describe("before_tool_call hook integration", () => { ); }); - it("blocks tool execution when hook returns block=true", async () => { + it("returns first-class blocked tool result when hook returns block=true", async () => { beforeToolCallHook = installBeforeToolCallHook({ runBeforeToolCallImpl: async () => ({ block: true, @@ -138,7 +138,14 @@ describe("before_tool_call hook integration", () => { await expect( tool.execute("call-3", { cmd: "rm -rf /" }, undefined, extensionContext), - ).rejects.toThrow("blocked"); + ).resolves.toEqual({ + content: [{ type: "text", text: "blocked" }], + details: { + status: "blocked", + deniedReason: "plugin-before-tool-call", + reason: "blocked", + }, + }); expect(execute).not.toHaveBeenCalled(); }); @@ -156,7 +163,14 @@ describe("before_tool_call hook integration", () => { await expect( tool.execute("call-stop", { cmd: "rm -rf /" }, undefined, extensionContext), - ).rejects.toThrow("blocked-high"); + ).resolves.toEqual({ + content: [{ type: "text", text: "blocked-high" }], + details: { + status: "blocked", + deniedReason: "plugin-before-tool-call", + reason: "blocked-high", + }, + }); expect(high).toHaveBeenCalledTimes(1); expect(low).not.toHaveBeenCalled(); @@ -194,6 +208,7 @@ describe("before_tool_call hook integration", () => { await tool.execute("call-5", "not-an-object", undefined, extensionContext); + expect(execute).toHaveBeenCalledWith("call-5", "not-an-object", undefined, extensionContext); expect(beforeToolCallHook).toHaveBeenCalledWith( { toolName: "read", diff --git a/src/agents/pi-tools.before-tool-call.ts b/src/agents/pi-tools.before-tool-call.ts index 37faa2c2e5a..1cb46fa9ad2 100644 --- a/src/agents/pi-tools.before-tool-call.ts +++ b/src/agents/pi-tools.before-tool-call.ts @@ -16,7 +16,12 @@ import type { SessionState } from "../logging/diagnostic-session-state.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; import { copyPluginToolMeta } from "../plugins/tools.js"; -import { PluginApprovalResolutions, type PluginApprovalResolution } from "../plugins/types.js"; +import { runTrustedToolPolicies } from "../plugins/trusted-tool-policy.js"; +import { + PluginApprovalResolutions, + type PluginApprovalResolution, + type PluginHookBeforeToolCallResult, +} from "../plugins/types.js"; import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; import { isPlainObject } from "../utils.js"; import { copyChannelAgentToolMeta } from "./channel-tools.js"; @@ -34,7 +39,18 @@ export type HookContext = { loopDetection?: ToolLoopDetectionConfig; }; -type HookOutcome = { blocked: true; reason: string } | { blocked: false; params: unknown }; +type HookBlockedKind = "veto" | "failure"; +type HookBlockedReason = "plugin-before-tool-call" | "plugin-approval" | "tool-loop"; +type HookOutcome = + | { + blocked: true; + kind?: HookBlockedKind; + deniedReason?: HookBlockedReason; + reason: string; + params?: unknown; + } + | { blocked: false; params: unknown }; +type PluginApprovalRequest = NonNullable; const log = createSubsystemLogger("agents/tools"); const BEFORE_TOOL_CALL_WRAPPED = Symbol("beforeToolCallWrapped"); @@ -45,6 +61,23 @@ const MAX_TRACKED_ADJUSTED_PARAMS = 1024; const LOOP_WARNING_BUCKET_SIZE = 10; const MAX_LOOP_WARNING_KEYS = 256; +/** + * Error used when before_tool_call intentionally vetoes a tool call. + */ +export class BeforeToolCallBlockedError extends Error { + constructor(readonly reason: string) { + super(reason); + this.name = "BeforeToolCallBlockedError"; + } +} + +/** + * Returns true when an error represents an intentional before_tool_call veto. + */ +export function isBeforeToolCallBlockedError(err: unknown): err is BeforeToolCallBlockedError { + return err instanceof BeforeToolCallBlockedError; +} + const loadBeforeToolCallRuntime = createLazyRuntimeSurface( () => import("./pi-tools.before-tool-call.runtime.js"), ({ beforeToolCallRuntime }) => beforeToolCallRuntime, @@ -98,6 +131,193 @@ function unwrapErrorCause(err: unknown): unknown { return err; } +async function requestPluginToolApproval(params: { + approval: PluginApprovalRequest; + toolName: string; + toolCallId?: string; + ctx?: HookContext; + signal?: AbortSignal; + baseParams: unknown; + overrideParams?: unknown; +}): Promise { + const approval = params.approval; + const safeOnResolution = (resolution: PluginApprovalResolution): void => { + const onResolution = approval.onResolution; + if (typeof onResolution !== "function") { + return; + } + try { + void Promise.resolve(onResolution(resolution)).catch((err) => { + log.warn(`plugin onResolution callback failed: ${String(err)}`); + }); + } catch (err) { + log.warn(`plugin onResolution callback failed: ${String(err)}`); + } + }; + try { + const requestResult: { + id?: string; + status?: string; + decision?: string | null; + } = await callGatewayTool( + "plugin.approval.request", + // Buffer beyond the approval timeout so the gateway can clean up + // and respond before the client-side RPC timeout fires. + { timeoutMs: (approval.timeoutMs ?? 120_000) + 10_000 }, + { + pluginId: approval.pluginId, + title: approval.title, + description: approval.description, + severity: approval.severity, + toolName: params.toolName, + toolCallId: params.toolCallId, + agentId: params.ctx?.agentId, + sessionKey: params.ctx?.sessionKey, + timeoutMs: approval.timeoutMs ?? 120_000, + twoPhase: true, + }, + { expectFinal: false }, + ); + const id = requestResult?.id; + if (!id) { + safeOnResolution(PluginApprovalResolutions.CANCELLED); + return { + blocked: true, + kind: "failure", + deniedReason: "plugin-approval", + reason: approval.description || "Plugin approval request failed", + params: params.baseParams, + }; + } + const hasImmediateDecision = Object.prototype.hasOwnProperty.call( + requestResult ?? {}, + "decision", + ); + let decision: string | null | undefined; + if (hasImmediateDecision) { + decision = requestResult?.decision; + if (decision === null) { + safeOnResolution(PluginApprovalResolutions.CANCELLED); + return { + blocked: true, + kind: "failure", + deniedReason: "plugin-approval", + reason: "Plugin approval unavailable (no approval route)", + params: params.baseParams, + }; + } + } else { + // Wait for the decision, but abort early if the agent run is cancelled + // so the user isn't blocked for the full approval timeout. + const waitPromise: Promise<{ + id?: string; + decision?: string | null; + }> = callGatewayTool( + "plugin.approval.waitDecision", + // Buffer beyond the approval timeout so the gateway can clean up + // and respond before the client-side RPC timeout fires. + { timeoutMs: (approval.timeoutMs ?? 120_000) + 10_000 }, + { id }, + ); + let waitResult: { id?: string; decision?: string | null } | undefined; + if (params.signal) { + let onAbort: (() => void) | undefined; + const abortPromise = new Promise((_, reject) => { + if (params.signal!.aborted) { + reject(params.signal!.reason); + return; + } + onAbort = () => reject(params.signal!.reason); + params.signal!.addEventListener("abort", onAbort, { once: true }); + }); + try { + waitResult = await Promise.race([waitPromise, abortPromise]); + } finally { + if (onAbort) { + params.signal.removeEventListener("abort", onAbort); + } + } + } else { + waitResult = await waitPromise; + } + decision = waitResult?.decision; + } + const resolution: PluginApprovalResolution = + decision === PluginApprovalResolutions.ALLOW_ONCE || + decision === PluginApprovalResolutions.ALLOW_ALWAYS || + decision === PluginApprovalResolutions.DENY + ? decision + : PluginApprovalResolutions.TIMEOUT; + safeOnResolution(resolution); + if ( + decision === PluginApprovalResolutions.ALLOW_ONCE || + decision === PluginApprovalResolutions.ALLOW_ALWAYS + ) { + return { + blocked: false, + params: mergeParamsWithApprovalOverrides(params.baseParams, params.overrideParams), + }; + } + if (decision === PluginApprovalResolutions.DENY) { + return { + blocked: true, + kind: "failure", + deniedReason: "plugin-approval", + reason: "Denied by user", + params: params.baseParams, + }; + } + const timeoutBehavior = approval.timeoutBehavior ?? "deny"; + if (timeoutBehavior === "allow") { + return { + blocked: false, + params: mergeParamsWithApprovalOverrides(params.baseParams, params.overrideParams), + }; + } + return { + blocked: true, + kind: "failure", + deniedReason: "plugin-approval", + reason: "Approval timed out", + params: params.baseParams, + }; + } catch (err) { + safeOnResolution(PluginApprovalResolutions.CANCELLED); + if (isAbortSignalCancellation(err, params.signal)) { + log.warn(`plugin approval wait cancelled by run abort: ${String(err)}`); + return { + blocked: true, + kind: "failure", + deniedReason: "plugin-approval", + reason: "Approval cancelled (run aborted)", + params: params.baseParams, + }; + } + log.warn(`plugin approval gateway request failed; blocking tool call: ${String(err)}`); + return { + blocked: true, + kind: "failure", + deniedReason: "plugin-approval", + reason: "Plugin approval required (gateway unavailable)", + params: params.baseParams, + }; + } +} + +export function buildBlockedToolResult(params: { + reason: string; + deniedReason?: HookBlockedReason; +}) { + return { + content: [{ type: "text" as const, text: params.reason }], + details: { + status: "blocked", + deniedReason: params.deniedReason ?? "plugin-before-tool-call", + reason: params.reason, + }, + }; +} + function summarizeToolParams(params: unknown): DiagnosticToolParamsSummary { if (params === null) { return { kind: "null" }; @@ -216,7 +436,10 @@ export async function runBeforeToolCallHook(args: { }); return { blocked: true, + kind: "failure", + deniedReason: "tool-loop", reason: loopResult.message, + params, }; } const baseWarningKey = loopResult.warningKey ?? `${loopResult.detector}:${toolName}`; @@ -248,10 +471,6 @@ export async function runBeforeToolCallHook(args: { } const hookRunner = getGlobalHookRunner(); - if (!hookRunner?.hasHooks("before_tool_call")) { - return { blocked: false, params: args.params }; - } - try { const normalizedParams = isPlainObject(params) ? params : {}; const toolContext = { @@ -263,7 +482,7 @@ export async function runBeforeToolCallHook(args: { ...(args.ctx?.trace && { trace: freezeDiagnosticTraceContext(args.ctx.trace) }), ...(args.toolCallId && { toolCallId: args.toolCallId }), }; - const hookResult = await hookRunner.runBeforeToolCall( + const trustedPolicyResult = await runTrustedToolPolicies( { toolName, params: normalizedParams, @@ -272,172 +491,82 @@ export async function runBeforeToolCallHook(args: { }, toolContext, ); + if (trustedPolicyResult?.block) { + return { + blocked: true, + kind: "veto", + deniedReason: "plugin-before-tool-call", + reason: trustedPolicyResult.blockReason || "Tool call blocked by trusted plugin policy", + params, + }; + } + if (trustedPolicyResult?.requireApproval) { + return await requestPluginToolApproval({ + approval: trustedPolicyResult.requireApproval, + toolName, + toolCallId: args.toolCallId, + ctx: args.ctx, + signal: args.signal, + baseParams: params, + overrideParams: trustedPolicyResult.params, + }); + } + const policyAdjustedParams = trustedPolicyResult?.params ?? params; + if (!hookRunner?.hasHooks("before_tool_call")) { + return { blocked: false, params: policyAdjustedParams }; + } + const hookEventParams = isPlainObject(policyAdjustedParams) ? policyAdjustedParams : {}; + const hookResult = await hookRunner.runBeforeToolCall( + { + toolName, + params: hookEventParams, + ...(args.ctx?.runId && { runId: args.ctx.runId }), + ...(args.toolCallId && { toolCallId: args.toolCallId }), + }, + toolContext, + ); if (hookResult?.block) { return { blocked: true, + kind: "veto", + deniedReason: "plugin-before-tool-call", reason: hookResult.blockReason || "Tool call blocked by plugin hook", + params: policyAdjustedParams, }; } if (hookResult?.requireApproval) { - const approval = hookResult.requireApproval; - const safeOnResolution = (resolution: PluginApprovalResolution): void => { - const onResolution = approval.onResolution; - if (typeof onResolution !== "function") { - return; - } - try { - void Promise.resolve(onResolution(resolution)).catch((err) => { - log.warn(`plugin onResolution callback failed: ${String(err)}`); - }); - } catch (err) { - log.warn(`plugin onResolution callback failed: ${String(err)}`); - } - }; - try { - const requestResult: { - id?: string; - status?: string; - decision?: string | null; - } = await callGatewayTool( - "plugin.approval.request", - // Buffer beyond the approval timeout so the gateway can clean up - // and respond before the client-side RPC timeout fires. - { timeoutMs: (approval.timeoutMs ?? 120_000) + 10_000 }, - { - pluginId: approval.pluginId, - title: approval.title, - description: approval.description, - severity: approval.severity, - toolName, - toolCallId: args.toolCallId, - agentId: args.ctx?.agentId, - sessionKey: args.ctx?.sessionKey, - timeoutMs: approval.timeoutMs ?? 120_000, - twoPhase: true, - }, - { expectFinal: false }, - ); - const id = requestResult?.id; - if (!id) { - safeOnResolution(PluginApprovalResolutions.CANCELLED); - return { - blocked: true, - reason: approval.description || "Plugin approval request failed", - }; - } - const hasImmediateDecision = Object.prototype.hasOwnProperty.call( - requestResult ?? {}, - "decision", - ); - let decision: string | null | undefined; - if (hasImmediateDecision) { - decision = requestResult?.decision; - if (decision === null) { - safeOnResolution(PluginApprovalResolutions.CANCELLED); - return { - blocked: true, - reason: "Plugin approval unavailable (no approval route)", - }; - } - } else { - // Wait for the decision, but abort early if the agent run is cancelled - // so the user isn't blocked for the full approval timeout. - const waitPromise: Promise<{ - id?: string; - decision?: string | null; - }> = callGatewayTool( - "plugin.approval.waitDecision", - // Buffer beyond the approval timeout so the gateway can clean up - // and respond before the client-side RPC timeout fires. - { timeoutMs: (approval.timeoutMs ?? 120_000) + 10_000 }, - { id }, - ); - let waitResult: { id?: string; decision?: string | null } | undefined; - if (args.signal) { - let onAbort: (() => void) | undefined; - const abortPromise = new Promise((_, reject) => { - if (args.signal!.aborted) { - reject(args.signal!.reason); - return; - } - onAbort = () => reject(args.signal!.reason); - args.signal!.addEventListener("abort", onAbort, { once: true }); - }); - try { - waitResult = await Promise.race([waitPromise, abortPromise]); - } finally { - if (onAbort) { - args.signal.removeEventListener("abort", onAbort); - } - } - } else { - waitResult = await waitPromise; - } - decision = waitResult?.decision; - } - const resolution: PluginApprovalResolution = - decision === PluginApprovalResolutions.ALLOW_ONCE || - decision === PluginApprovalResolutions.ALLOW_ALWAYS || - decision === PluginApprovalResolutions.DENY - ? decision - : PluginApprovalResolutions.TIMEOUT; - safeOnResolution(resolution); - if ( - decision === PluginApprovalResolutions.ALLOW_ONCE || - decision === PluginApprovalResolutions.ALLOW_ALWAYS - ) { - return { - blocked: false, - params: mergeParamsWithApprovalOverrides(params, hookResult.params), - }; - } - if (decision === PluginApprovalResolutions.DENY) { - return { blocked: true, reason: "Denied by user" }; - } - const timeoutBehavior = approval.timeoutBehavior ?? "deny"; - if (timeoutBehavior === "allow") { - return { - blocked: false, - params: mergeParamsWithApprovalOverrides(params, hookResult.params), - }; - } - return { blocked: true, reason: "Approval timed out" }; - } catch (err) { - safeOnResolution(PluginApprovalResolutions.CANCELLED); - if (isAbortSignalCancellation(err, args.signal)) { - log.warn(`plugin approval wait cancelled by run abort: ${String(err)}`); - return { - blocked: true, - reason: "Approval cancelled (run aborted)", - }; - } - log.warn(`plugin approval gateway request failed; blocking tool call: ${String(err)}`); - return { - blocked: true, - reason: "Plugin approval required (gateway unavailable)", - }; - } + return await requestPluginToolApproval({ + approval: hookResult.requireApproval, + toolName, + toolCallId: args.toolCallId, + ctx: args.ctx, + signal: args.signal, + baseParams: policyAdjustedParams, + overrideParams: hookResult.params, + }); } if (hookResult?.params) { return { blocked: false, - params: mergeParamsWithApprovalOverrides(params, hookResult.params), + params: mergeParamsWithApprovalOverrides(policyAdjustedParams, hookResult.params), }; } + return { blocked: false, params: policyAdjustedParams }; } catch (err) { const toolCallId = args.toolCallId ? ` toolCallId=${args.toolCallId}` : ""; const cause = unwrapErrorCause(err); log.error(`before_tool_call hook failed: tool=${toolName}${toolCallId} error=${String(cause)}`); return { blocked: true, + kind: "failure", + deniedReason: "plugin-before-tool-call", reason: BEFORE_TOOL_CALL_HOOK_FAILURE_REASON, + params, }; } - - return { blocked: false, params }; } export function wrapToolWithBeforeToolCallHook( @@ -460,7 +589,40 @@ export function wrapToolWithBeforeToolCallHook( signal, }); if (outcome.blocked) { - throw new Error(outcome.reason); + if (outcome.kind !== "veto") { + throw new Error(outcome.reason); + } + const normalizedToolName = normalizeToolName(toolName || "tool"); + const trace = ctx?.trace + ? freezeDiagnosticTraceContext(createChildDiagnosticTraceContext(ctx.trace)) + : undefined; + const eventBase = { + ...(ctx?.runId && { runId: ctx.runId }), + ...(ctx?.sessionKey && { sessionKey: ctx.sessionKey }), + ...(ctx?.sessionId && { sessionId: ctx.sessionId }), + ...(trace && { trace }), + toolName: normalizedToolName, + ...(toolCallId && { toolCallId }), + paramsSummary: summarizeToolParams(outcome.params ?? params), + }; + emitTrustedDiagnosticEvent({ + type: "tool.execution.blocked", + ...eventBase, + reason: outcome.reason, + deniedReason: outcome.deniedReason ?? "plugin-before-tool-call", + }); + const blockedResult = buildBlockedToolResult({ + reason: outcome.reason, + deniedReason: outcome.deniedReason ?? "plugin-before-tool-call", + }); + await recordLoopOutcome({ + ctx, + toolName: normalizedToolName, + toolParams: outcome.params ?? params, + toolCallId, + result: blockedResult, + }); + return blockedResult; } if (toolCallId) { const adjustedParamsKey = buildAdjustedParamsKey({ runId: ctx?.runId, toolCallId }); diff --git a/src/agents/test-helpers/fast-tool-stubs.ts b/src/agents/test-helpers/fast-tool-stubs.ts index 7947203f377..a8f1f759e79 100644 --- a/src/agents/test-helpers/fast-tool-stubs.ts +++ b/src/agents/test-helpers/fast-tool-stubs.ts @@ -33,6 +33,8 @@ vi.mock("../tools/web-tools.js", () => ({ })); vi.mock("../../plugins/tools.js", () => ({ + buildPluginToolMetadataKey: (pluginId: string, toolName: string) => + JSON.stringify([pluginId, toolName]), copyPluginToolMeta: (_from: unknown, to: unknown) => to, getPluginToolMeta: () => undefined, resolvePluginTools: () => [], diff --git a/src/agents/tools-effective-inventory.test.ts b/src/agents/tools-effective-inventory.test.ts index 86e9262a1f5..61828c3d543 100644 --- a/src/agents/tools-effective-inventory.test.ts +++ b/src/agents/tools-effective-inventory.test.ts @@ -1,4 +1,6 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; import type { createOpenClawCodingTools } from "./pi-tools.js"; import type { AnyAgentTool } from "./tools/common.js"; @@ -49,6 +51,8 @@ vi.mock("./pi-tools.js", () => ({ vi.mock("../plugins/tools.js", () => ({ getPluginToolMeta: (tool: { name: string }) => effectiveInventoryState.pluginMeta[tool.name], + buildPluginToolMetadataKey: (pluginId: string, toolName: string) => + JSON.stringify([pluginId, toolName]), })); vi.mock("./channel-tools.js", () => ({ @@ -101,6 +105,7 @@ describe("resolveEffectiveToolInventory", () => { effectiveInventoryState.createToolsMock = vi.fn( (_options) => effectiveInventoryState.tools, ); + setActivePluginRegistry(createEmptyPluginRegistry()); }); it("groups core, plugin, and channel tools from the effective runtime set", async () => { @@ -190,6 +195,74 @@ describe("resolveEffectiveToolInventory", () => { expect(labels).toEqual(["Lookup (docs)", "Lookup (jira)"]); }); + it("projects plugin tool metadata into the effective inventory", async () => { + const registry = createEmptyPluginRegistry(); + registry.toolMetadata = [ + { + pluginId: "docs", + pluginName: "Docs", + source: "fixture", + metadata: { + toolName: "docs_lookup", + displayName: "Docs Search", + description: "Curated docs lookup.", + risk: "low", + tags: ["docs", "fixture"], + }, + }, + ]; + setActivePluginRegistry(registry); + const { resolveEffectiveToolInventory } = await loadHarness({ + tools: [mockTool({ name: "docs_lookup", label: "Lookup", description: "Search docs" })], + pluginMeta: { docs_lookup: { pluginId: "docs" } }, + }); + + const result = resolveEffectiveToolInventory({ cfg: {} }); + + expect(result.groups[0]?.tools[0]).toEqual({ + id: "docs_lookup", + label: "Docs Search", + description: "Curated docs lookup.", + rawDescription: "Curated docs lookup.", + source: "plugin", + pluginId: "docs", + risk: "low", + tags: ["docs", "fixture"], + }); + }); + + it("does not let one plugin project metadata onto another plugin tool", async () => { + const registry = createEmptyPluginRegistry(); + registry.toolMetadata = [ + { + pluginId: "spoofing-plugin", + pluginName: "Spoofing Plugin", + source: "fixture", + metadata: { + toolName: "docs_lookup", + displayName: "Spoofed Docs Search", + risk: "high", + }, + }, + ]; + setActivePluginRegistry(registry); + const { resolveEffectiveToolInventory } = await loadHarness({ + tools: [mockTool({ name: "docs_lookup", label: "Lookup", description: "Search docs" })], + pluginMeta: { docs_lookup: { pluginId: "docs" } }, + }); + + const result = resolveEffectiveToolInventory({ cfg: {} }); + + expect(result.groups[0]?.tools[0]).toEqual({ + id: "docs_lookup", + label: "Lookup", + description: "Search docs", + rawDescription: "Search docs", + source: "plugin", + pluginId: "docs", + }); + }); + it("prefers displaySummary over raw description", async () => { const { resolveEffectiveToolInventory } = await loadHarness({ tools: [ diff --git a/src/agents/tools-effective-inventory.ts b/src/agents/tools-effective-inventory.ts index 7f5dff439b7..abea7c48b7f 100644 --- a/src/agents/tools-effective-inventory.ts +++ b/src/agents/tools-effective-inventory.ts @@ -1,6 +1,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { extractModelCompat } from "../plugins/provider-model-compat.js"; -import { getPluginToolMeta } from "../plugins/tools.js"; +import { getActivePluginRegistry } from "../plugins/runtime.js"; +import { buildPluginToolMetadataKey, getPluginToolMeta } from "../plugins/tools.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, @@ -241,17 +242,35 @@ export function resolveEffectiveToolInventory( modelId: params.modelId, }); const profile = effectivePolicy.providerProfile ?? effectivePolicy.profile ?? "full"; + // Key metadata by plugin ownership and tool name so only the owning plugin can + // project display/risk metadata for its own tool. + const pluginToolMetadata = new Map( + (getActivePluginRegistry()?.toolMetadata ?? []).map((entry) => [ + buildPluginToolMetadataKey(entry.pluginId, entry.metadata.toolName), + entry.metadata, + ]), + ); const entries = disambiguateLabels( effectiveTools .map((tool) => { const source = resolveEffectiveToolSource(tool); + const metadata = source.pluginId + ? pluginToolMetadata.get(buildPluginToolMetadataKey(source.pluginId, tool.name)) + : undefined; return Object.assign( { id: tool.name, - label: resolveEffectiveToolLabel(tool), - description: summarizeToolDescription(tool), - rawDescription: resolveRawToolDescription(tool) || summarizeToolDescription(tool), + label: + normalizeOptionalString(metadata?.displayName) ?? resolveEffectiveToolLabel(tool), + description: + normalizeOptionalString(metadata?.description) ?? summarizeToolDescription(tool), + rawDescription: + normalizeOptionalString(metadata?.description) ?? + resolveRawToolDescription(tool) ?? + summarizeToolDescription(tool), + ...(metadata?.risk ? { risk: metadata.risk } : {}), + ...(metadata?.tags ? { tags: metadata.tags } : {}), }, source, ) satisfies EffectiveToolInventoryEntry; diff --git a/src/agents/tools-effective-inventory.types.ts b/src/agents/tools-effective-inventory.types.ts index 6b3f4b76ee2..266c80a5af0 100644 --- a/src/agents/tools-effective-inventory.types.ts +++ b/src/agents/tools-effective-inventory.types.ts @@ -10,6 +10,8 @@ export type EffectiveToolInventoryEntry = { source: EffectiveToolSource; pluginId?: string; channelId?: string; + risk?: "low" | "medium" | "high"; + tags?: string[]; }; export type EffectiveToolInventoryGroup = { diff --git a/src/auto-reply/reply/commands-allowlist.test.ts b/src/auto-reply/reply/commands-allowlist.test.ts index eb6e204c032..96d1e04a400 100644 --- a/src/auto-reply/reply/commands-allowlist.test.ts +++ b/src/auto-reply/reply/commands-allowlist.test.ts @@ -27,6 +27,7 @@ const addChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn()); const removeChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn()); vi.mock("../../config/config.js", () => ({ + getRuntimeConfig: () => ({}), readConfigFileSnapshot: readConfigFileSnapshotMock, validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock, replaceConfigFile: replaceConfigFileMock, diff --git a/src/auto-reply/reply/commands-plugin.test.ts b/src/auto-reply/reply/commands-plugin.test.ts index 97742b4503b..902733373e5 100644 --- a/src/auto-reply/reply/commands-plugin.test.ts +++ b/src/auto-reply/reply/commands-plugin.test.ts @@ -106,4 +106,80 @@ describe("handlePluginCommand", () => { }), ); }); + + it("continues the agent without leaking continueAgent into the reply payload", async () => { + matchPluginCommandMock.mockReturnValue({ + command: { name: "card" }, + args: "", + }); + executePluginCommandMock.mockResolvedValue({ + text: "from plugin", + continueAgent: true, + }); + + const result = await handlePluginCommand( + buildPluginParams("/card", { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig), + true, + ); + + expect(result).toEqual({ + shouldContinue: true, + reply: { text: "from plugin" }, + }); + }); + + it("enforces requiredScopes through the command handler path", async () => { + const actualCommands = await vi.importActual( + "../../plugins/commands.js", + ); + const handler = vi.fn().mockResolvedValue({ + text: "approved", + continueAgent: true, + }); + const command = { + pluginId: "approval-plugin", + pluginName: "Approval Plugin", + pluginRoot: "/tmp/approval-plugin", + name: "approve-deploy", + description: "Approve deployment", + requiredScopes: ["operator.approvals"], + handler, + }; + matchPluginCommandMock.mockReturnValue({ + command, + args: "", + }); + executePluginCommandMock.mockImplementation(actualCommands.executePluginCommand); + + const denied = await handlePluginCommand( + buildPluginParams("/approve-deploy", { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig), + true, + ); + + expect(denied).toEqual({ + shouldContinue: false, + reply: { text: "⚠️ This command requires gateway scope: operator.approvals." }, + }); + expect(handler).not.toHaveBeenCalled(); + + const allowedParams = buildPluginParams("/approve-deploy", { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig); + allowedParams.ctx.GatewayClientScopes = ["operator.approvals"]; + + const allowed = await handlePluginCommand(allowedParams, true); + + expect(allowed).toEqual({ + shouldContinue: true, + reply: { text: "approved" }, + }); + expect(handler).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/auto-reply/reply/commands-plugin.ts b/src/auto-reply/reply/commands-plugin.ts index eec3303d17e..176294527d4 100644 --- a/src/auto-reply/reply/commands-plugin.ts +++ b/src/auto-reply/reply/commands-plugin.ts @@ -55,9 +55,12 @@ export const handlePluginCommand: CommandHandler = async ( : undefined, threadParentId: normalizeOptionalString(params.ctx.ThreadParentId), }); + const shouldContinue = result.continueAgent === true; + const { continueAgent: _continueAgent, ...reply } = result; + void _continueAgent; return { - shouldContinue: false, - reply: result, + shouldContinue, + reply: Object.keys(reply).length > 0 ? reply : undefined, }; }; diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 45a8cde9677..677d7db044c 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -112,6 +112,26 @@ export type SessionPluginDebugEntry = { lines: string[]; }; +export type SessionPluginJsonValue = + | string + | number + | boolean + | null + | SessionPluginJsonValue[] + | { [key: string]: SessionPluginJsonValue }; + +export type SessionPluginNextTurnInjection = { + id: string; + pluginId: string; + pluginName?: string; + text: string; + idempotencyKey?: string; + placement: "prepend_context" | "append_context"; + ttlMs?: number; + createdAt: number; + metadata?: SessionPluginJsonValue; +}; + export type SessionEntry = { /** * Last delivered heartbeat payload (used to suppress duplicate heartbeat notifications). @@ -128,6 +148,10 @@ export type SessionEntry = { heartbeatIsolatedBaseSessionKey?: string; /** Heartbeat task state (task name -> last run timestamp ms). */ heartbeatTaskState?: Record; + /** Plugin-owned session state, grouped by plugin id then extension namespace. */ + pluginExtensions?: Record>; + /** Durable one-shot prompt additions drained before the next agent turn. */ + pluginNextTurnInjections?: Record; sessionId: string; updatedAt: number; sessionFile?: string; diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index 6b4f81411e0..a55f04aae2a 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -85,6 +85,7 @@ const METHOD_SCOPE_GROUPS: Record = { "models.authStatus", "tools.catalog", "tools.effective", + "plugins.uiDescriptors", "agents.list", "agent.identity.get", "skills.status", @@ -176,6 +177,7 @@ const METHOD_SCOPE_GROUPS: Record = { "cron.remove", "cron.run", "sessions.patch", + "sessions.pluginPatch", "sessions.reset", "sessions.delete", "sessions.compact", diff --git a/src/gateway/operator-scopes.ts b/src/gateway/operator-scopes.ts index 1dca6e41f0a..45fcf68e8ca 100644 --- a/src/gateway/operator-scopes.ts +++ b/src/gateway/operator-scopes.ts @@ -12,3 +12,20 @@ export type OperatorScope = | typeof APPROVALS_SCOPE | typeof PAIRING_SCOPE | typeof TALK_SECRETS_SCOPE; + +const KNOWN_OPERATOR_SCOPE_VALUES: readonly OperatorScope[] = [ + ADMIN_SCOPE, + READ_SCOPE, + WRITE_SCOPE, + APPROVALS_SCOPE, + PAIRING_SCOPE, + TALK_SECRETS_SCOPE, +]; + +export const KNOWN_OPERATOR_SCOPES: ReadonlySet = new Set( + KNOWN_OPERATOR_SCOPE_VALUES, +); + +export function isOperatorScope(value: unknown): value is OperatorScope { + return typeof value === "string" && KNOWN_OPERATOR_SCOPES.has(value as OperatorScope); +} diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 20be8b61cce..064fc47b749 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -155,6 +155,8 @@ import { PluginApprovalRequestParamsSchema, type PluginApprovalResolveParams, PluginApprovalResolveParamsSchema, + type PluginsUiDescriptorsParams, + PluginsUiDescriptorsParamsSchema, ErrorCodes, type ErrorShape, ErrorShapeSchema, @@ -255,6 +257,8 @@ import { SessionsMessagesUnsubscribeParamsSchema, type SessionsPatchParams, SessionsPatchParamsSchema, + type SessionsPluginPatchParams, + SessionsPluginPatchParamsSchema, type SessionsPreviewParams, SessionsPreviewParamsSchema, type SessionsResetParams, @@ -427,6 +431,9 @@ export const validateSessionsAbortParams = ajv.compile(SessionsAbortParamsSchema); export const validateSessionsPatchParams = ajv.compile(SessionsPatchParamsSchema); +export const validateSessionsPluginPatchParams = ajv.compile( + SessionsPluginPatchParamsSchema, +); export const validateSessionsResetParams = ajv.compile(SessionsResetParamsSchema); export const validateSessionsDeleteParams = ajv.compile( @@ -552,6 +559,9 @@ export const validatePluginApprovalRequestParams = ajv.compile( PluginApprovalResolveParamsSchema, ); +export const validatePluginsUiDescriptorsParams = ajv.compile( + PluginsUiDescriptorsParamsSchema, +); export const validateExecApprovalsNodeGetParams = ajv.compile( ExecApprovalsNodeGetParamsSchema, ); @@ -656,6 +666,7 @@ export { SessionsSendParamsSchema, SessionsAbortParamsSchema, SessionsPatchParamsSchema, + SessionsPluginPatchParamsSchema, SessionsResetParamsSchema, SessionsDeleteParamsSchema, SessionsCompactParamsSchema, @@ -712,6 +723,7 @@ export { AgentsListResultSchema, CommandsListParamsSchema, CommandsListResultSchema, + PluginsUiDescriptorsParamsSchema, ModelsListParamsSchema, SkillsStatusParamsSchema, ToolsCatalogParamsSchema, diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index e036e895f86..c93b4bb06e1 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -17,4 +17,5 @@ export * from "./schema/sessions.js"; export * from "./schema/snapshot.js"; export * from "./schema/types.js"; export * from "./schema/plugin-approvals.js"; +export * from "./schema/plugins.js"; export * from "./schema/wizard.js"; diff --git a/src/gateway/protocol/schema/agents-models-skills.ts b/src/gateway/protocol/schema/agents-models-skills.ts index e9c8472aca2..9ffbe230b13 100644 --- a/src/gateway/protocol/schema/agents-models-skills.ts +++ b/src/gateway/protocol/schema/agents-models-skills.ts @@ -374,6 +374,10 @@ export const ToolCatalogEntrySchema = Type.Object( source: Type.Union([Type.Literal("core"), Type.Literal("plugin")]), pluginId: Type.Optional(NonEmptyString), optional: Type.Optional(Type.Boolean()), + risk: Type.Optional( + Type.Union([Type.Literal("low"), Type.Literal("medium"), Type.Literal("high")]), + ), + tags: Type.Optional(Type.Array(NonEmptyString)), defaultProfiles: Type.Array( Type.Union([ Type.Literal("minimal"), @@ -415,6 +419,10 @@ export const ToolsEffectiveEntrySchema = Type.Object( source: Type.Union([Type.Literal("core"), Type.Literal("plugin"), Type.Literal("channel")]), pluginId: Type.Optional(NonEmptyString), channelId: Type.Optional(NonEmptyString), + risk: Type.Optional( + Type.Union([Type.Literal("low"), Type.Literal("medium"), Type.Literal("high")]), + ), + tags: Type.Optional(Type.Array(NonEmptyString)), }, { additionalProperties: false }, ); diff --git a/src/gateway/protocol/schema/plugins.ts b/src/gateway/protocol/schema/plugins.ts new file mode 100644 index 00000000000..6adb0e120e6 --- /dev/null +++ b/src/gateway/protocol/schema/plugins.ts @@ -0,0 +1,34 @@ +import { Type } from "typebox"; +import { NonEmptyString } from "./primitives.js"; + +export const PluginJsonValueSchema = Type.Unknown(); + +export const PluginControlUiDescriptorSchema = Type.Object( + { + id: NonEmptyString, + pluginId: NonEmptyString, + pluginName: Type.Optional(NonEmptyString), + surface: Type.Union([ + Type.Literal("session"), + Type.Literal("tool"), + Type.Literal("run"), + Type.Literal("settings"), + ]), + label: NonEmptyString, + description: Type.Optional(Type.String()), + placement: Type.Optional(Type.String()), + schema: Type.Optional(PluginJsonValueSchema), + requiredScopes: Type.Optional(Type.Array(NonEmptyString)), + }, + { additionalProperties: false }, +); + +export const PluginsUiDescriptorsParamsSchema = Type.Object({}, { additionalProperties: false }); + +export const PluginsUiDescriptorsResultSchema = Type.Object( + { + ok: Type.Literal(true), + descriptors: Type.Array(PluginControlUiDescriptorSchema), + }, + { additionalProperties: false }, +); diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index c3df0479f82..e53abc53946 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -161,6 +161,11 @@ import { PluginApprovalRequestParamsSchema, PluginApprovalResolveParamsSchema, } from "./plugin-approvals.js"; +import { + PluginControlUiDescriptorSchema, + PluginsUiDescriptorsParamsSchema, + PluginsUiDescriptorsResultSchema, +} from "./plugins.js"; import { PushTestParamsSchema, PushTestResultSchema } from "./push.js"; import { SecretsReloadParamsSchema, @@ -186,6 +191,8 @@ import { SessionsMessagesSubscribeParamsSchema, SessionsMessagesUnsubscribeParamsSchema, SessionsPatchParamsSchema, + SessionsPluginPatchParamsSchema, + SessionsPluginPatchResultSchema, SessionsPreviewParamsSchema, SessionsResetParamsSchema, SessionsResolveParamsSchema, @@ -266,6 +273,8 @@ export const ProtocolSchemas = { SessionsMessagesUnsubscribeParams: SessionsMessagesUnsubscribeParamsSchema, SessionsAbortParams: SessionsAbortParamsSchema, SessionsPatchParams: SessionsPatchParamsSchema, + SessionsPluginPatchParams: SessionsPluginPatchParamsSchema, + SessionsPluginPatchResult: SessionsPluginPatchResultSchema, SessionsResetParams: SessionsResetParamsSchema, SessionsDeleteParams: SessionsDeleteParamsSchema, SessionsCompactParams: SessionsCompactParamsSchema, @@ -365,6 +374,9 @@ export const ProtocolSchemas = { ExecApprovalResolveParams: ExecApprovalResolveParamsSchema, PluginApprovalRequestParams: PluginApprovalRequestParamsSchema, PluginApprovalResolveParams: PluginApprovalResolveParamsSchema, + PluginControlUiDescriptor: PluginControlUiDescriptorSchema, + PluginsUiDescriptorsParams: PluginsUiDescriptorsParamsSchema, + PluginsUiDescriptorsResult: PluginsUiDescriptorsResultSchema, DevicePairListParams: DevicePairListParamsSchema, DevicePairApproveParams: DevicePairApproveParamsSchema, DevicePairRejectParams: DevicePairRejectParamsSchema, diff --git a/src/gateway/protocol/schema/sessions.ts b/src/gateway/protocol/schema/sessions.ts index d8fb83f1e2c..70572320dbf 100644 --- a/src/gateway/protocol/schema/sessions.ts +++ b/src/gateway/protocol/schema/sessions.ts @@ -1,4 +1,5 @@ import { Type } from "typebox"; +import { PluginJsonValueSchema } from "./plugins.js"; import { NonEmptyString, SessionLabelString } from "./primitives.js"; export const SessionCompactionCheckpointReasonSchema = Type.Union([ @@ -172,6 +173,26 @@ export const SessionsPatchParamsSchema = Type.Object( { additionalProperties: false }, ); +export const SessionsPluginPatchParamsSchema = Type.Object( + { + key: NonEmptyString, + pluginId: NonEmptyString, + namespace: NonEmptyString, + value: Type.Optional(PluginJsonValueSchema), + unset: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false }, +); + +export const SessionsPluginPatchResultSchema = Type.Object( + { + ok: Type.Literal(true), + key: NonEmptyString, + value: Type.Optional(PluginJsonValueSchema), + }, + { additionalProperties: false }, +); + export const SessionsResetParamsSchema = Type.Object( { key: NonEmptyString, diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index 1d312c1dd09..82cde9ecda4 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -58,6 +58,8 @@ export type SessionsMessagesSubscribeParams = SchemaType<"SessionsMessagesSubscr export type SessionsMessagesUnsubscribeParams = SchemaType<"SessionsMessagesUnsubscribeParams">; export type SessionsAbortParams = SchemaType<"SessionsAbortParams">; export type SessionsPatchParams = SchemaType<"SessionsPatchParams">; +export type SessionsPluginPatchParams = SchemaType<"SessionsPluginPatchParams">; +export type SessionsPluginPatchResult = SchemaType<"SessionsPluginPatchResult">; export type SessionsResetParams = SchemaType<"SessionsResetParams">; export type SessionsDeleteParams = SchemaType<"SessionsDeleteParams">; export type SessionsCompactParams = SchemaType<"SessionsCompactParams">; @@ -119,6 +121,9 @@ export type ModelsListResult = SchemaType<"ModelsListResult">; export type CommandEntry = SchemaType<"CommandEntry">; export type CommandsListParams = SchemaType<"CommandsListParams">; export type CommandsListResult = SchemaType<"CommandsListResult">; +export type PluginControlUiDescriptor = SchemaType<"PluginControlUiDescriptor">; +export type PluginsUiDescriptorsParams = SchemaType<"PluginsUiDescriptorsParams">; +export type PluginsUiDescriptorsResult = SchemaType<"PluginsUiDescriptorsResult">; export type SkillsStatusParams = SchemaType<"SkillsStatusParams">; export type ToolsCatalogParams = SchemaType<"ToolsCatalogParams">; export type ToolCatalogProfile = SchemaType<"ToolCatalogProfile">; diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index d912d648457..6723a2a6f65 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -45,6 +45,7 @@ const BASE_METHODS = [ "plugin.approval.request", "plugin.approval.waitDecision", "plugin.approval.resolve", + "plugins.uiDescriptors", "wizard.start", "wizard.next", "wizard.cancel", @@ -97,6 +98,7 @@ const BASE_METHODS = [ "sessions.send", "sessions.abort", "sessions.patch", + "sessions.pluginPatch", "sessions.reset", "sessions.delete", "sessions.compact", diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index a14974a014a..84c90f4a4d6 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -23,6 +23,7 @@ import { modelsHandlers } from "./server-methods/models.js"; import { nativeHookRelayHandlers } from "./server-methods/native-hook-relay.js"; import { nodePendingHandlers } from "./server-methods/nodes-pending.js"; import { nodeHandlers } from "./server-methods/nodes.js"; +import { pluginHostHookHandlers } from "./server-methods/plugin-host-hooks.js"; import { pushHandlers } from "./server-methods/push.js"; import { sendHandlers } from "./server-methods/send.js"; import { sessionsHandlers } from "./server-methods/sessions.js"; @@ -88,6 +89,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { ...modelsHandlers, ...modelsAuthStatusHandlers, ...nativeHookRelayHandlers, + ...pluginHostHookHandlers, ...configHandlers, ...wizardHandlers, ...talkHandlers, diff --git a/src/gateway/server-methods/plugin-host-hooks.ts b/src/gateway/server-methods/plugin-host-hooks.ts new file mode 100644 index 00000000000..ce1e6639ec3 --- /dev/null +++ b/src/gateway/server-methods/plugin-host-hooks.ts @@ -0,0 +1,31 @@ +import { getActivePluginRegistry } from "../../plugins/runtime.js"; +import { + ErrorCodes, + errorShape, + formatValidationErrors, + validatePluginsUiDescriptorsParams, +} from "../protocol/index.js"; +import type { GatewayRequestHandlers } from "./types.js"; + +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); + }, +}; diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index d5d015fc8ae..ef1fab3bfa6 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -27,6 +27,8 @@ import { type SessionPatchHookEvent, } from "../../hooks/internal-hooks.js"; import { formatErrorMessage } from "../../infra/errors.js"; +import { patchPluginSessionExtension } from "../../plugins/host-hook-state.js"; +import { isPluginJsonValue } from "../../plugins/host-hooks.js"; import { normalizeAgentId, parseAgentSessionKey, @@ -38,6 +40,7 @@ import { normalizeOptionalString, readStringValue, } from "../../shared/string-coerce.js"; +import { ADMIN_SCOPE } from "../operator-scopes.js"; import { GATEWAY_CLIENT_IDS } from "../protocol/client-info.js"; import { ErrorCodes, @@ -54,6 +57,7 @@ import { validateSessionsMessagesSubscribeParams, validateSessionsMessagesUnsubscribeParams, validateSessionsPatchParams, + validateSessionsPluginPatchParams, validateSessionsPreviewParams, validateSessionsResetParams, validateSessionsResolveParams, @@ -237,6 +241,7 @@ function emitSessionsChanged( runtimeMs: sessionRow.runtimeMs, compactionCheckpointCount: sessionRow.compactionCheckpointCount, latestCompactionCheckpoint: sessionRow.latestCompactionCheckpoint, + pluginExtensions: sessionRow.pluginExtensions, } : {}), }, @@ -1372,6 +1377,81 @@ export const sessionsHandlers: GatewayRequestHandlers = { reason: "patch", }); }, + "sessions.pluginPatch": async ({ params, respond, context, client, isWebchatConnect }) => { + if ( + !assertValidParams(params, validateSessionsPluginPatchParams, "sessions.pluginPatch", respond) + ) { + return; + } + const key = requireSessionKey(params.key, respond); + if (!key) { + return; + } + if (rejectWebchatSessionMutation({ action: "patch", client, isWebchatConnect, respond })) { + return; + } + const scopes = Array.isArray(client?.connect.scopes) ? client.connect.scopes : []; + if (!scopes.includes(ADMIN_SCOPE)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `sessions.pluginPatch requires gateway scope: ${ADMIN_SCOPE}`, + ), + ); + return; + } + const pluginId = normalizeOptionalString(params.pluginId); + const namespace = normalizeOptionalString(params.namespace); + if (!pluginId || !namespace) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "pluginId and namespace are required"), + ); + return; + } + if (params.unset === true && params.value !== undefined) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "sessions.pluginPatch cannot specify both unset and value", + ), + ); + return; + } + if (params.value !== undefined && !isPluginJsonValue(params.value)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "sessions.pluginPatch value must be JSON-compatible", + ), + ); + return; + } + const patched = await patchPluginSessionExtension({ + cfg: context.getRuntimeConfig(), + sessionKey: key, + pluginId, + namespace, + value: params.value, + unset: params.unset === true, + }); + if (!patched.ok) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, patched.error)); + return; + } + respond(true, { ok: true, key: patched.key, value: patched.value }, undefined); + emitSessionsChanged(context, { + sessionKey: patched.key, + reason: "plugin-patch", + }); + }, "sessions.reset": async ({ params, respond, context }) => { if (!assertValidParams(params, validateSessionsResetParams, "sessions.reset", respond)) { return; diff --git a/src/gateway/server-methods/tools-catalog.test.ts b/src/gateway/server-methods/tools-catalog.test.ts index a99b67fc3e8..e3cedd61bdf 100644 --- a/src/gateway/server-methods/tools-catalog.test.ts +++ b/src/gateway/server-methods/tools-catalog.test.ts @@ -17,6 +17,8 @@ vi.mock("../../agents/agent-scope.js", () => ({ const pluginToolMetaState = new Map(); vi.mock("../../plugins/tools.js", () => ({ + buildPluginToolMetadataKey: (pluginId: string, toolName: string) => + JSON.stringify([pluginId, toolName]), resolvePluginTools: vi.fn(() => [ { name: "voice_call", label: "voice_call", description: "Plugin calling tool" }, { diff --git a/src/gateway/server-methods/tools-catalog.ts b/src/gateway/server-methods/tools-catalog.ts index c74c8ccd875..66aaa112d29 100644 --- a/src/gateway/server-methods/tools-catalog.ts +++ b/src/gateway/server-methods/tools-catalog.ts @@ -11,7 +11,12 @@ import { } from "../../agents/tool-catalog.js"; import { summarizeToolDescriptionText } from "../../agents/tool-description-summary.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { getPluginToolMeta, resolvePluginTools } from "../../plugins/tools.js"; +import { getActivePluginRegistry } from "../../plugins/runtime.js"; +import { + buildPluginToolMetadataKey, + getPluginToolMeta, + resolvePluginTools, +} from "../../plugins/tools.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { ErrorCodes, @@ -29,6 +34,8 @@ type ToolCatalogEntry = { source: "core" | "plugin"; pluginId?: string; optional?: boolean; + risk?: "low" | "medium" | "high"; + tags?: string[]; defaultProfiles: Array<"minimal" | "coding" | "messaging" | "full">; }; @@ -94,6 +101,16 @@ function buildPluginGroups(params: { allowGatewaySubagentBinding: true, }); const groups = new Map(); + // Key metadata by plugin ownership and tool name so we only project metadata that + // was registered BY the tool's owning plugin. Without this scoping, plugin-X + // could override the catalog label/description/risk/tags for another plugin's + // tool by registering metadata with the same toolName. + const pluginToolMetadata = new Map( + (getActivePluginRegistry()?.toolMetadata ?? []).map((entry) => [ + buildPluginToolMetadataKey(entry.pluginId, entry.metadata.toolName), + entry.metadata, + ]), + ); for (const tool of pluginTools) { const meta = getPluginToolMeta(tool); const pluginId = meta?.pluginId ?? "plugin"; @@ -107,16 +124,26 @@ function buildPluginGroups(params: { pluginId, tools: [], } as ToolCatalogGroup); + const ownedMetadata = meta?.pluginId + ? pluginToolMetadata.get(buildPluginToolMetadataKey(meta.pluginId, tool.name)) + : undefined; existing.tools.push({ id: tool.name, - label: normalizeOptionalString(tool.label) ?? tool.name, + label: + normalizeOptionalString(ownedMetadata?.displayName) ?? + normalizeOptionalString(tool.label) ?? + tool.name, description: summarizeToolDescriptionText({ - rawDescription: typeof tool.description === "string" ? tool.description : undefined, + rawDescription: + ownedMetadata?.description ?? + (typeof tool.description === "string" ? tool.description : undefined), displaySummary: tool.displaySummary, }), source: "plugin", pluginId, optional: meta?.optional, + risk: ownedMetadata?.risk, + tags: ownedMetadata?.tags, defaultProfiles: [], }); groups.set(groupId, existing); diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index 25c13e4445e..a10dbd04f4a 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -30,6 +30,8 @@ import { createInternalHookEvent, triggerInternalHook } from "../hooks/internal- import { getSessionBindingService } from "../infra/outbound/session-binding-service.js"; import { closeTrackedBrowserTabsForSessions } from "../plugin-sdk/browser-maintenance.js"; import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; +import { runPluginHostCleanup } from "../plugins/host-hook-cleanup.js"; +import { getActivePluginRegistry } from "../plugins/runtime.js"; import { isSubagentSessionKey, normalizeAgentId, @@ -412,6 +414,17 @@ export async function cleanupSessionBeforeMutation(params: { if (cleanupError) { return cleanupError; } + const pluginCleanup = await runPluginHostCleanup({ + cfg: params.cfg, + registry: getActivePluginRegistry(), + reason: params.reason === "session-reset" ? "reset" : "delete", + sessionKey: params.target.canonicalKey ?? params.key, + }); + for (const failure of pluginCleanup.failures) { + logVerbose( + `plugin host cleanup failed for ${failure.pluginId}/${failure.hookId}: ${String(failure.error)}`, + ); + } return await closeAcpRuntimeForSession({ cfg: params.cfg, sessionKey: params.legacyKey ?? params.canonicalKey ?? params.target.canonicalKey ?? params.key, diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 291c20f2511..9d6707941a8 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -50,6 +50,7 @@ import { } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; +import { projectPluginSessionExtensionsSync } from "../plugins/host-hook-state.js"; import { DEFAULT_AGENT_ID, normalizeAgentId, @@ -1421,6 +1422,9 @@ export function buildGatewaySessionRow(params: { const thinkingProvider = rowModelProvider ?? DEFAULT_PROVIDER; const thinkingModel = rowModel ?? DEFAULT_MODEL; const thinkingLevels = listThinkingLevelOptions(thinkingProvider, thinkingModel); + const pluginExtensions = entry + ? projectPluginSessionExtensionsSync({ sessionKey: key, entry }) + : []; return { key, @@ -1484,6 +1488,7 @@ export function buildGatewaySessionRow(params: { lastThreadId: deliveryFields.lastThreadId ?? entry?.lastThreadId, compactionCheckpointCount: entry?.compactionCheckpoints?.length, latestCompactionCheckpoint, + pluginExtensions: pluginExtensions.length > 0 ? pluginExtensions : undefined, }; } diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts index 125b4820cdd..530250bdd2d 100644 --- a/src/gateway/session-utils.types.ts +++ b/src/gateway/session-utils.types.ts @@ -1,5 +1,6 @@ import type { ChatType } from "../channels/chat-type.js"; import type { SessionCompactionCheckpoint, SessionEntry } from "../config/sessions/types.js"; +import type { PluginSessionExtensionProjection } from "../plugins/host-hooks.js"; import type { GatewayAgentRow as SharedGatewayAgentRow, SessionsListResultBase, @@ -82,6 +83,7 @@ export type GatewaySessionRow = { lastThreadId?: SessionEntry["lastThreadId"]; compactionCheckpointCount?: number; latestCompactionCheckpoint?: SessionCompactionCheckpoint; + pluginExtensions?: PluginSessionExtensionProjection[]; }; export type GatewayAgentRow = SharedGatewayAgentRow; diff --git a/src/gateway/test-helpers.plugin-registry.ts b/src/gateway/test-helpers.plugin-registry.ts index d78e6cf91fd..17f9e451119 100644 --- a/src/gateway/test-helpers.plugin-registry.ts +++ b/src/gateway/test-helpers.plugin-registry.ts @@ -34,6 +34,13 @@ function createStubPluginRegistry(): PluginRegistry { services: [], gatewayDiscoveryServices: [], commands: [], + sessionExtensions: [], + trustedToolPolicies: [], + toolMetadata: [], + controlUiDescriptors: [], + runtimeLifecycles: [], + agentEventSubscriptions: [], + sessionSchedulerJobs: [], conversationBindingResolvedHandlers: [], diagnostics: [], }; diff --git a/src/hooks/install.test.ts b/src/hooks/install.test.ts index be7253405c8..3c40fd553cd 100644 --- a/src/hooks/install.test.ts +++ b/src/hooks/install.test.ts @@ -2,7 +2,6 @@ import { createHash, randomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { runCommandWithTimeout } from "../process/exec.js"; import { expectSingleNpmPackIgnoreScriptsCall } from "../test-utils/exec-assertions.js"; import { expectInstallUsesIgnoreScripts, @@ -11,12 +10,21 @@ import { mockNpmPackMetadataResult, } from "../test-utils/npm-spec-install-test-helpers.js"; import { isAddressInUseError } from "./gmail-watcher-errors.js"; -import { - installHooksFromArchive, - installHooksFromNpmSpec, - installHooksFromPath, -} from "./install.js"; -import * as hookInstallRuntime from "./install.runtime.js"; + +type InstallHooksFromArchive = typeof import("./install.js").installHooksFromArchive; +type InstallHooksFromPath = typeof import("./install.js").installHooksFromPath; + +const runCommandWithTimeoutMock = vi.fn(); + +vi.mock("../process/exec.js", () => ({ + runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), +})); + +vi.resetModules(); + +const { installHooksFromArchive, installHooksFromNpmSpec, installHooksFromPath } = + await import("./install.js"); +const hookInstallRuntime = await import("./install.runtime.js"); const fixtureRoot = path.join(process.cwd(), ".tmp", `openclaw-hook-install-${randomUUID()}`); const sharedArchiveDir = path.join(fixtureRoot, "_archives"); @@ -32,10 +40,6 @@ const tarEvilIdBuffer = fs.readFileSync(path.join(fixturesDir, "tar-evil-id.tar" const tarReservedIdBuffer = fs.readFileSync(path.join(fixturesDir, "tar-reserved-id.tar")); const npmPackHooksBuffer = fs.readFileSync(path.join(fixturesDir, "npm-pack-hooks.tgz")); -vi.mock("../process/exec.js", () => ({ - runCommandWithTimeout: vi.fn(), -})); - function makeTempDir() { const dir = path.join(fixtureRoot, `case-${tempDirIndex++}`); fs.mkdirSync(dir); @@ -51,7 +55,7 @@ afterAll(() => { }); beforeEach(() => { - vi.clearAllMocks(); + runCommandWithTimeoutMock.mockReset(); }); beforeAll(() => { @@ -77,7 +81,7 @@ function writeArchiveFixture(params: { fileName: string; contents: Buffer }) { } function expectInstallFailureContains( - result: Awaited>, + result: Awaited>, snippets: string[], ) { expect(result.ok).toBe(false); @@ -116,7 +120,7 @@ async function installArchiveFixture(params: { fileName: string; contents: Buffe } function expectPathInstallFailureContains( - result: Awaited>, + result: Awaited>, snippet: string, ) { expect(result.ok).toBe(false); @@ -229,7 +233,7 @@ describe("installHooksFromPath", () => { "utf-8", ); - const run = vi.mocked(runCommandWithTimeout); + const run = runCommandWithTimeoutMock; await expectInstallUsesIgnoreScripts({ run, install: async () => @@ -366,7 +370,7 @@ describe("installHooksFromNpmSpec", () => { it("uses --ignore-scripts for npm pack and cleans up temp dir", async () => { const stateDir = makeTempDir(); - const run = vi.mocked(runCommandWithTimeout); + const run = runCommandWithTimeoutMock; let packTmpDir = ""; const packedName = "test-hooks-0.0.1.tgz"; run.mockImplementation(async (argv, opts) => { @@ -410,7 +414,7 @@ describe("installHooksFromNpmSpec", () => { expect(fs.existsSync(path.join(result.targetDir, "hooks", "one-hook", "HOOK.md"))).toBe(true); expectSingleNpmPackIgnoreScriptsCall({ - calls: run.mock.calls, + calls: run.mock.calls as Array<[unknown, unknown]>, expectedSpec: "@openclaw/test-hooks@0.0.1", }); @@ -419,7 +423,7 @@ describe("installHooksFromNpmSpec", () => { }); it("aborts when integrity drift callback rejects the fetched artifact", async () => { - const run = vi.mocked(runCommandWithTimeout); + const run = runCommandWithTimeoutMock; mockNpmPackMetadataResult(run, { id: "@openclaw/test-hooks@0.0.1", name: "@openclaw/test-hooks", @@ -446,7 +450,7 @@ describe("installHooksFromNpmSpec", () => { it("rejects invalid npm spec shapes", async () => { await expectUnsupportedNpmSpec((spec) => installHooksFromNpmSpec({ spec })); - const run = vi.mocked(runCommandWithTimeout); + const run = runCommandWithTimeoutMock; mockNpmPackMetadataResult(run, { id: "@openclaw/test-hooks@0.0.2-beta.1", name: "@openclaw/test-hooks", diff --git a/src/infra/diagnostic-events.ts b/src/infra/diagnostic-events.ts index ab3c055bdae..8ed0caf6bfb 100644 --- a/src/infra/diagnostic-events.ts +++ b/src/infra/diagnostic-events.ts @@ -215,6 +215,12 @@ export type DiagnosticToolExecutionErrorEvent = DiagnosticToolExecutionBaseEvent errorCode?: string; }; +export type DiagnosticToolExecutionBlockedEvent = DiagnosticToolExecutionBaseEvent & { + type: "tool.execution.blocked"; + deniedReason: string; + reason: string; +}; + export type DiagnosticExecProcessCompletedEvent = DiagnosticBaseEvent & { type: "exec.process.completed"; sessionKey?: string; @@ -439,6 +445,7 @@ export type DiagnosticEventPayload = | DiagnosticToolExecutionStartedEvent | DiagnosticToolExecutionCompletedEvent | DiagnosticToolExecutionErrorEvent + | DiagnosticToolExecutionBlockedEvent | DiagnosticExecProcessCompletedEvent | DiagnosticRunStartedEvent | DiagnosticRunCompletedEvent diff --git a/src/logging/diagnostic-stability.ts b/src/logging/diagnostic-stability.ts index b625e172b30..189f4ca7e6f 100644 --- a/src/logging/diagnostic-stability.ts +++ b/src/logging/diagnostic-stability.ts @@ -286,6 +286,11 @@ function sanitizeDiagnosticEvent(event: DiagnosticEventPayload): DiagnosticStabi record.durationMs = event.durationMs; assignReasonCode(record, event.errorCategory); break; + case "tool.execution.blocked": + record.toolName = event.toolName; + record.outcome = "blocked"; + assignReasonCode(record, event.deniedReason); + break; case "exec.process.completed": record.target = event.target; record.mode = event.mode; diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 937244a73b6..2aa57fadf5e 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -48,6 +48,26 @@ export type { OpenClawPluginService, OpenClawPluginServiceContext, PluginCommandContext, + PluginCommandResult, + PluginAgentEventSubscriptionRegistration, + PluginAgentTurnPrepareEvent, + PluginAgentTurnPrepareResult, + PluginControlUiDescriptor, + PluginHeartbeatPromptContributionEvent, + PluginHeartbeatPromptContributionResult, + PluginJsonValue, + PluginNextTurnInjection, + PluginNextTurnInjectionEnqueueResult, + PluginNextTurnInjectionRecord, + PluginRunContextGetParams, + PluginRunContextPatch, + PluginRuntimeLifecycleRegistration, + PluginSessionSchedulerJobHandle, + PluginSessionSchedulerJobRegistration, + PluginSessionExtensionRegistration, + PluginSessionExtensionProjection, + PluginToolMetadataRegistration, + PluginTrustedToolPolicyRegistration, PluginLogger, ProviderAuthContext, ProviderAuthDoctorHintContext, diff --git a/src/plugin-sdk/plugin-entry.ts b/src/plugin-sdk/plugin-entry.ts index 49930b0fd24..d551f57434e 100644 --- a/src/plugin-sdk/plugin-entry.ts +++ b/src/plugin-sdk/plugin-entry.ts @@ -81,6 +81,25 @@ import type { SpeechProviderPlugin, PluginCommandContext, PluginCommandResult, + PluginAgentEventSubscriptionRegistration, + PluginAgentTurnPrepareEvent, + PluginAgentTurnPrepareResult, + PluginControlUiDescriptor, + PluginHeartbeatPromptContributionEvent, + PluginHeartbeatPromptContributionResult, + PluginJsonValue, + PluginNextTurnInjection, + PluginNextTurnInjectionEnqueueResult, + PluginNextTurnInjectionRecord, + PluginRunContextGetParams, + PluginRunContextPatch, + PluginRuntimeLifecycleRegistration, + PluginSessionSchedulerJobHandle, + PluginSessionSchedulerJobRegistration, + PluginSessionExtensionRegistration, + PluginSessionExtensionProjection, + PluginToolMetadataRegistration, + PluginTrustedToolPolicyRegistration, } from "../plugins/types.js"; import { createCachedLazyValueGetter } from "./lazy-value.js"; @@ -104,6 +123,25 @@ export type { OpenClawPluginToolFactory, PluginCommandContext, PluginCommandResult, + PluginAgentEventSubscriptionRegistration, + PluginAgentTurnPrepareEvent, + PluginAgentTurnPrepareResult, + PluginControlUiDescriptor, + PluginHeartbeatPromptContributionEvent, + PluginHeartbeatPromptContributionResult, + PluginJsonValue, + PluginNextTurnInjection, + PluginNextTurnInjectionEnqueueResult, + PluginNextTurnInjectionRecord, + PluginRunContextGetParams, + PluginRunContextPatch, + PluginRuntimeLifecycleRegistration, + PluginSessionSchedulerJobHandle, + PluginSessionSchedulerJobRegistration, + PluginSessionExtensionRegistration, + PluginSessionExtensionProjection, + PluginToolMetadataRegistration, + PluginTrustedToolPolicyRegistration, OpenClawPluginConfigSchema, OpenClawPluginHttpRouteHandler, ProviderDiscoveryContext, diff --git a/src/plugin-sdk/plugin-test-api.ts b/src/plugin-sdk/plugin-test-api.ts index 671f6947257..ad86ce47903 100644 --- a/src/plugin-sdk/plugin-test-api.ts +++ b/src/plugin-sdk/plugin-test-api.ts @@ -46,6 +46,21 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi registerCodexAppServerExtensionFactory() {}, registerAgentToolResultMiddleware() {}, registerDetachedTaskRuntime() {}, + registerSessionExtension() {}, + enqueueNextTurnInjection: async (injection) => ({ + enqueued: false, + id: "", + sessionKey: injection.sessionKey, + }), + registerTrustedToolPolicy() {}, + registerToolMetadata() {}, + registerControlUiDescriptor() {}, + registerRuntimeLifecycle() {}, + registerAgentEventSubscription() {}, + setRunContext: () => false, + getRunContext: () => undefined, + clearRunContext() {}, + registerSessionSchedulerJob: () => undefined, registerMemoryCapability() {}, registerMemoryPromptSection() {}, registerMemoryPromptSupplement() {}, diff --git a/src/plugins/api-builder.ts b/src/plugins/api-builder.ts index d9ce38a5e0c..b0eb4483e3d 100644 --- a/src/plugins/api-builder.ts +++ b/src/plugins/api-builder.ts @@ -52,6 +52,17 @@ export type BuildPluginApiParams = { | "registerAgentHarness" | "registerCodexAppServerExtensionFactory" | "registerAgentToolResultMiddleware" + | "registerSessionExtension" + | "enqueueNextTurnInjection" + | "registerTrustedToolPolicy" + | "registerToolMetadata" + | "registerControlUiDescriptor" + | "registerRuntimeLifecycle" + | "registerAgentEventSubscription" + | "setRunContext" + | "getRunContext" + | "clearRunContext" + | "registerSessionSchedulerJob" | "registerDetachedTaskRuntime" | "registerMemoryCapability" | "registerMemoryPromptSection" @@ -110,6 +121,21 @@ const noopRegisterCodexAppServerExtensionFactory: OpenClawPluginApi["registerCod () => {}; const noopRegisterAgentToolResultMiddleware: OpenClawPluginApi["registerAgentToolResultMiddleware"] = () => {}; +const noopRegisterSessionExtension: OpenClawPluginApi["registerSessionExtension"] = () => {}; +const noopEnqueueNextTurnInjection: OpenClawPluginApi["enqueueNextTurnInjection"] = async ( + injection, +) => ({ enqueued: false, id: "", sessionKey: injection.sessionKey }); +const noopRegisterTrustedToolPolicy: OpenClawPluginApi["registerTrustedToolPolicy"] = () => {}; +const noopRegisterToolMetadata: OpenClawPluginApi["registerToolMetadata"] = () => {}; +const noopRegisterControlUiDescriptor: OpenClawPluginApi["registerControlUiDescriptor"] = () => {}; +const noopRegisterRuntimeLifecycle: OpenClawPluginApi["registerRuntimeLifecycle"] = () => {}; +const noopRegisterAgentEventSubscription: OpenClawPluginApi["registerAgentEventSubscription"] = + () => {}; +const noopSetRunContext: OpenClawPluginApi["setRunContext"] = () => false; +const noopGetRunContext: OpenClawPluginApi["getRunContext"] = () => undefined; +const noopClearRunContext: OpenClawPluginApi["clearRunContext"] = () => {}; +const noopRegisterSessionSchedulerJob: OpenClawPluginApi["registerSessionSchedulerJob"] = () => + undefined; const noopRegisterDetachedTaskRuntime: OpenClawPluginApi["registerDetachedTaskRuntime"] = () => {}; const noopRegisterMemoryCapability: OpenClawPluginApi["registerMemoryCapability"] = () => {}; const noopRegisterMemoryPromptSection: OpenClawPluginApi["registerMemoryPromptSection"] = () => {}; @@ -184,6 +210,20 @@ export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi handlers.registerCodexAppServerExtensionFactory ?? noopRegisterCodexAppServerExtensionFactory, registerAgentToolResultMiddleware: handlers.registerAgentToolResultMiddleware ?? noopRegisterAgentToolResultMiddleware, + registerSessionExtension: handlers.registerSessionExtension ?? noopRegisterSessionExtension, + enqueueNextTurnInjection: handlers.enqueueNextTurnInjection ?? noopEnqueueNextTurnInjection, + registerTrustedToolPolicy: handlers.registerTrustedToolPolicy ?? noopRegisterTrustedToolPolicy, + registerToolMetadata: handlers.registerToolMetadata ?? noopRegisterToolMetadata, + registerControlUiDescriptor: + handlers.registerControlUiDescriptor ?? noopRegisterControlUiDescriptor, + registerRuntimeLifecycle: handlers.registerRuntimeLifecycle ?? noopRegisterRuntimeLifecycle, + registerAgentEventSubscription: + handlers.registerAgentEventSubscription ?? noopRegisterAgentEventSubscription, + setRunContext: handlers.setRunContext ?? noopSetRunContext, + getRunContext: handlers.getRunContext ?? noopGetRunContext, + clearRunContext: handlers.clearRunContext ?? noopClearRunContext, + registerSessionSchedulerJob: + handlers.registerSessionSchedulerJob ?? noopRegisterSessionSchedulerJob, registerDetachedTaskRuntime: handlers.registerDetachedTaskRuntime ?? noopRegisterDetachedTaskRuntime, registerMemoryCapability: handlers.registerMemoryCapability ?? noopRegisterMemoryCapability, diff --git a/src/plugins/captured-registration.ts b/src/plugins/captured-registration.ts index 31a2ad4f626..4afbb10f1ee 100644 --- a/src/plugins/captured-registration.ts +++ b/src/plugins/captured-registration.ts @@ -6,6 +6,15 @@ import type { import { normalizeAgentToolResultMiddlewareRuntimes } from "./agent-tool-result-middleware.js"; import { buildPluginApi } from "./api-builder.js"; import type { CodexAppServerExtensionFactory } from "./codex-app-server-extension-types.js"; +import type { + PluginAgentEventSubscriptionRegistration, + PluginControlUiDescriptor, + PluginRuntimeLifecycleRegistration, + PluginSessionSchedulerJobRegistration, + PluginSessionExtensionRegistration, + PluginToolMetadataRegistration, + PluginTrustedToolPolicyRegistration, +} from "./host-hooks.js"; import type { MemoryEmbeddingProviderAdapter } from "./memory-embedding-providers.js"; import type { PluginAgentToolResultMiddlewareRegistration } from "./registry-types.js"; import type { PluginRuntime } from "./runtime/types.js"; @@ -56,6 +65,13 @@ export type CapturedPluginRegistration = { webSearchProviders: WebSearchProviderPlugin[]; migrationProviders: MigrationProviderPlugin[]; memoryEmbeddingProviders: MemoryEmbeddingProviderAdapter[]; + sessionExtensions: PluginSessionExtensionRegistration[]; + trustedToolPolicies: PluginTrustedToolPolicyRegistration[]; + toolMetadata: PluginToolMetadataRegistration[]; + controlUiDescriptors: PluginControlUiDescriptor[]; + runtimeLifecycles: PluginRuntimeLifecycleRegistration[]; + agentEventSubscriptions: PluginAgentEventSubscriptionRegistration[]; + sessionSchedulerJobs: PluginSessionSchedulerJobRegistration[]; tools: AnyAgentTool[]; }; @@ -84,6 +100,13 @@ export function createCapturedPluginRegistration(params?: { const webSearchProviders: WebSearchProviderPlugin[] = []; const migrationProviders: MigrationProviderPlugin[] = []; const memoryEmbeddingProviders: MemoryEmbeddingProviderAdapter[] = []; + const sessionExtensions: PluginSessionExtensionRegistration[] = []; + const trustedToolPolicies: PluginTrustedToolPolicyRegistration[] = []; + const toolMetadata: PluginToolMetadataRegistration[] = []; + const controlUiDescriptors: PluginControlUiDescriptor[] = []; + const runtimeLifecycles: PluginRuntimeLifecycleRegistration[] = []; + const agentEventSubscriptions: PluginAgentEventSubscriptionRegistration[] = []; + const sessionSchedulerJobs: PluginSessionSchedulerJobRegistration[] = []; const tools: AnyAgentTool[] = []; const pluginId = params?.id ?? "captured-plugin-registration"; const pluginName = params?.name ?? "Captured Plugin Registration"; @@ -114,6 +137,13 @@ export function createCapturedPluginRegistration(params?: { webSearchProviders, migrationProviders, memoryEmbeddingProviders, + sessionExtensions, + trustedToolPolicies, + toolMetadata, + controlUiDescriptors, + runtimeLifecycles, + agentEventSubscriptions, + sessionSchedulerJobs, tools, api: buildPluginApi({ id: pluginId, @@ -210,6 +240,33 @@ export function createCapturedPluginRegistration(params?: { registerMemoryEmbeddingProvider(adapter: MemoryEmbeddingProviderAdapter) { memoryEmbeddingProviders.push(adapter); }, + registerSessionExtension(extension: PluginSessionExtensionRegistration) { + sessionExtensions.push(extension); + }, + registerTrustedToolPolicy(policy: PluginTrustedToolPolicyRegistration) { + trustedToolPolicies.push(policy); + }, + registerToolMetadata(metadata: PluginToolMetadataRegistration) { + toolMetadata.push(metadata); + }, + registerControlUiDescriptor(descriptor: PluginControlUiDescriptor) { + controlUiDescriptors.push(descriptor); + }, + registerRuntimeLifecycle(lifecycle: PluginRuntimeLifecycleRegistration) { + runtimeLifecycles.push(lifecycle); + }, + registerAgentEventSubscription(subscription: PluginAgentEventSubscriptionRegistration) { + agentEventSubscriptions.push(subscription); + }, + registerSessionSchedulerJob(job: PluginSessionSchedulerJobRegistration) { + sessionSchedulerJobs.push(job); + return { + id: job.id, + pluginId: "captured-plugin-registration", + sessionKey: job.sessionKey, + kind: job.kind, + }; + }, registerTool(tool) { if (typeof tool !== "function") { tools.push(tool); diff --git a/src/plugins/channel-plugin-ids.test.ts b/src/plugins/channel-plugin-ids.test.ts index c536550519d..b126fab7217 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import type { InstalledPluginIndex, InstalledPluginIndexRecord } from "./installed-plugin-index.js"; +import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js"; const listPotentialConfiguredChannelIds = vi.hoisted(() => vi.fn()); const listPotentialConfiguredChannelPresenceSignals = vi.hoisted(() => vi.fn()); @@ -15,6 +17,9 @@ const hasMeaningfulChannelConfig = vi.hoisted(() => }), ); const loadPluginManifestRegistry = vi.hoisted(() => vi.fn()); +const loadPluginManifestRegistryForInstalledIndex = vi.hoisted(() => vi.fn()); +const loadPluginManifestRegistryForPluginRegistry = vi.hoisted(() => vi.fn()); +const loadPluginRegistrySnapshot = vi.hoisted(() => vi.fn()); vi.mock("../channels/config-presence.js", () => ({ listPotentialConfiguredChannelIds, @@ -31,6 +36,23 @@ vi.mock("./manifest-registry.js", async (importOriginal) => { }; }); +vi.mock("./manifest-registry-installed.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadPluginManifestRegistryForInstalledIndex, + }; +}); + +vi.mock("./plugin-registry.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadPluginManifestRegistryForPluginRegistry, + loadPluginRegistrySnapshot, + }; +}); + vi.mock("./installed-plugin-index-store.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -247,6 +269,74 @@ function createManifestRegistryFixtureWithWorkspaceDemoChannel() { }; } +function normalizeStartupAgentHarnesses(record: PluginManifestRecord): readonly string[] { + return [ + ...new Set([...(record.activation?.onAgentHarnesses ?? []), ...(record.cliBackends ?? [])]), + ].toSorted((left, right) => left.localeCompare(right)); +} + +function hasRuntimeContractSurface(record: PluginManifestRecord): boolean { + return record.providers.length > 0 || record.cliBackends.length > 0; +} + +function hasPluginKind(record: PluginManifestRecord, kind: string): boolean { + return Array.isArray(record.kind) ? record.kind.includes(kind as never) : record.kind === kind; +} + +function createInstalledPluginRecordFixture( + record: PluginManifestRecord, +): InstalledPluginIndexRecord { + const memory = hasPluginKind(record, "memory"); + return { + pluginId: record.id, + manifestPath: record.manifestPath, + manifestHash: `test-${record.id}`, + source: record.source, + rootDir: record.rootDir, + origin: record.origin, + enabled: true, + ...(record.enabledByDefault === true ? { enabledByDefault: true } : {}), + startup: { + sidecar: record.channels.length === 0 && !hasRuntimeContractSurface(record) && !memory, + memory, + deferConfiguredChannelFullLoadUntilAfterListen: + record.startupDeferConfiguredChannelFullLoadUntilAfterListen === true, + agentHarnesses: normalizeStartupAgentHarnesses(record), + }, + compat: [], + }; +} + +function createInstalledPluginIndexFixture( + registry: PluginManifestRegistry = loadPluginManifestRegistry(), +): InstalledPluginIndex { + return { + version: 1, + hostContractVersion: "test", + compatRegistryVersion: "test", + migrationVersion: 1, + policyHash: "test", + generatedAtMs: 0, + installRecords: {}, + plugins: registry.plugins.map(createInstalledPluginRecordFixture), + diagnostics: registry.diagnostics, + }; +} + +function filterManifestRegistryForInstalledIndex(params: { + pluginIds?: readonly string[]; + includeDisabled?: boolean; +}): PluginManifestRegistry { + const registry = loadPluginManifestRegistry() as PluginManifestRegistry; + const pluginIdSet = params.pluginIds?.length ? new Set(params.pluginIds) : null; + return { + ...registry, + plugins: pluginIdSet + ? registry.plugins.filter((plugin) => pluginIdSet.has(plugin.id)) + : registry.plugins, + }; +} + function expectStartupPluginIds(params: { config: OpenClawConfig; activationSourceConfig?: OpenClawConfig; @@ -413,6 +503,15 @@ describe("resolveGatewayStartupPluginIds", () => { return true; }); loadPluginManifestRegistry.mockReset().mockReturnValue(createManifestRegistryFixture()); + loadPluginRegistrySnapshot + .mockReset() + .mockImplementation(() => createInstalledPluginIndexFixture()); + loadPluginManifestRegistryForInstalledIndex + .mockReset() + .mockImplementation(filterManifestRegistryForInstalledIndex); + loadPluginManifestRegistryForPluginRegistry + .mockReset() + .mockImplementation(() => loadPluginManifestRegistry()); }); it.each([ diff --git a/src/plugins/command-registration.ts b/src/plugins/command-registration.ts index e8bc2876aaf..385f2faa462 100644 --- a/src/plugins/command-registration.ts +++ b/src/plugins/command-registration.ts @@ -1,3 +1,4 @@ +import { isOperatorScope } from "../gateway/operator-scopes.js"; import { logVerbose } from "../globals.js"; import { normalizeLowercaseStringOrEmpty, @@ -23,24 +24,7 @@ import type { OpenClawPluginCommandDefinition } from "./types.js"; */ let reservedCommands: Set | undefined; -export type CommandRegistrationResult = { - ok: boolean; - error?: string; -}; - -export function validateCommandName(name: string): string | null { - const trimmed = normalizeOptionalLowercaseString(name) ?? ""; - - if (!trimmed) { - return "Command name cannot be empty"; - } - - // Must start with a letter, contain only letters, numbers, hyphens, underscores - // Note: trimmed is already lowercased, so no need for /i flag - if (!/^[a-z][a-z0-9_-]*$/.test(trimmed)) { - return "Command name must start with a letter and contain only letters, numbers, hyphens, and underscores"; - } - +function getReservedCommands(): Set { reservedCommands ??= new Set([ "help", "commands", @@ -74,8 +58,36 @@ export function validateCommandName(name: string): string | null { "elevated", "usage", ]); + return reservedCommands; +} - if (reservedCommands.has(trimmed)) { +export type CommandRegistrationResult = { + ok: boolean; + error?: string; +}; + +export function isReservedCommandName(name: string): boolean { + const trimmed = normalizeOptionalLowercaseString(name) ?? ""; + return Boolean(trimmed && getReservedCommands().has(trimmed)); +} + +export function validateCommandName( + name: string, + opts?: { allowReservedCommandNames?: boolean }, +): string | null { + const trimmed = normalizeOptionalLowercaseString(name) ?? ""; + + if (!trimmed) { + return "Command name cannot be empty"; + } + + // Must start with a letter, contain only letters, numbers, hyphens, underscores + // Note: trimmed is already lowercased, so no need for /i flag + if (!/^[a-z][a-z0-9_-]*$/.test(trimmed)) { + return "Command name must start with a letter and contain only letters, numbers, hyphens, and underscores"; + } + + if (!opts?.allowReservedCommandNames && getReservedCommands().has(trimmed)) { return `Command name "${trimmed}" is reserved by a built-in command`; } @@ -89,6 +101,7 @@ export function validateCommandName(name: string): string | null { */ export function validatePluginCommandDefinition( command: OpenClawPluginCommandDefinition, + opts?: { allowReservedCommandNames?: boolean }, ): string | null { if (typeof command.handler !== "function") { return "Command handler must be a function"; @@ -113,7 +126,20 @@ export function validatePluginCommandDefinition( return `Agent prompt guidance ${index + 1} cannot be empty`; } } - const nameError = validateCommandName(command.name.trim()); + if (command.requiredScopes !== undefined) { + if (!Array.isArray(command.requiredScopes)) { + return "Command requiredScopes must be an array of operator scopes"; + } + const unknownScope = (command.requiredScopes as readonly unknown[]).find( + (scope) => !isOperatorScope(scope), + ); + if (unknownScope) { + return typeof unknownScope === "string" + ? `Command requiredScopes contains unknown operator scope: ${unknownScope}` + : "Command requiredScopes contains unknown operator scope"; + } + } + const nameError = validateCommandName(command.name.trim(), opts); if (nameError) { return nameError; } @@ -160,14 +186,14 @@ export function listPluginInvocationKeys(command: OpenClawPluginCommandDefinitio export function registerPluginCommand( pluginId: string, command: OpenClawPluginCommandDefinition, - opts?: { pluginName?: string; pluginRoot?: string }, + opts?: { pluginName?: string; pluginRoot?: string; allowReservedCommandNames?: boolean }, ): CommandRegistrationResult { // Prevent registration while commands are being processed if (isPluginCommandRegistryLocked()) { return { ok: false, error: "Cannot register commands while processing is in progress" }; } - const definitionError = validatePluginCommandDefinition(command); + const definitionError = validatePluginCommandDefinition(command, opts); if (definitionError) { return { ok: false, error: definitionError }; } diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index 3e0c17d861b..27bc3c75c80 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -413,6 +413,25 @@ describe("registerPluginCommand", () => { }); }); + it("keeps reserved command bypass scoped to the primary command name", () => { + const result = registerPluginCommand( + "bundled-plugin", + createVoiceCommand({ + name: "status", + nativeNames: { + telegram: "help", + }, + }), + { allowReservedCommandNames: true }, + ); + + expect(result).toEqual({ + ok: false, + error: + 'Native command alias "telegram" invalid: Command name "help" is reserved by a built-in command', + }); + }); + it("shares plugin commands across duplicate module instances", async () => { const first = await importCommandsModule(`first-${Date.now()}`); const second = await importCommandsModule(`second-${Date.now()}`); diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index 8cfa4762e08..0314dfb5fc5 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -7,6 +7,7 @@ import { resolveConversationBindingContext } from "../channels/conversation-binding-context.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { ADMIN_SCOPE, isOperatorScope } from "../gateway/operator-scopes.js"; import { logVerbose } from "../globals.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { @@ -197,6 +198,27 @@ export async function executePluginCommand(params: { ); return { text: "⚠️ This command requires authorization." }; } + if (command.requiredScopes !== undefined && !Array.isArray(command.requiredScopes)) { + logVerbose(`Plugin command /${command.name} blocked: invalid requiredScopes configuration`); + return { text: "⚠️ This command has invalid gateway scope configuration." }; + } + const requiredScopes = command.requiredScopes ?? []; + const unknownScope = (requiredScopes as readonly unknown[]).find( + (scope) => !isOperatorScope(scope), + ); + if (unknownScope) { + logVerbose(`Plugin command /${command.name} blocked: unknown gateway scope`); + return { text: "⚠️ This command has invalid gateway scope configuration." }; + } + if (requiredScopes.length > 0 && params.gatewayClientScopes) { + const scopes = new Set(params.gatewayClientScopes ?? []); + const hasAdmin = scopes.has(ADMIN_SCOPE); + const missingScope = requiredScopes.find((scope) => !hasAdmin && !scopes.has(scope)); + if (missingScope) { + logVerbose(`Plugin command /${command.name} blocked: missing gateway scope ${missingScope}`); + return { text: `⚠️ This command requires gateway scope: ${missingScope}.` }; + } + } // Sanitize args before passing to handler const sanitizedArgs = sanitizeArgs(args); diff --git a/src/plugins/contracts/host-hook-fixture.ts b/src/plugins/contracts/host-hook-fixture.ts new file mode 100644 index 00000000000..2658bcc7bda --- /dev/null +++ b/src/plugins/contracts/host-hook-fixture.ts @@ -0,0 +1,74 @@ +import type { OpenClawPluginApi } from "../types.js"; + +export function registerHostHookFixture(api: OpenClawPluginApi) { + api.registerSessionExtension({ + namespace: "workflow", + description: "Generic approval-workflow state projection", + }); + api.registerToolMetadata({ + toolName: "approval_fixture_tool", + displayName: "Approval Fixture Tool", + description: "Fixture metadata for a plugin-owned tool", + risk: "medium", + tags: ["fixture", "approval"], + }); + api.registerControlUiDescriptor({ + id: "workflow-card", + surface: "session", + label: "Workflow Card", + description: "Generic Control UI descriptor for workflow state", + placement: "session-sidebar", + }); + api.registerRuntimeLifecycle({ + id: "workflow-cleanup", + description: "Generic cleanup hook for plugin-owned workflow state", + }); + api.registerAgentEventSubscription({ + id: "workflow-events", + description: "Generic sanitized agent-event subscription for workflow plugins", + streams: ["lifecycle", "tool"], + handle(event, ctx) { + if (event.stream === "tool") { + ctx.setRunContext("lastToolEvent", { + runId: event.runId, + seen: true, + }); + } + }, + }); + api.registerSessionSchedulerJob({ + id: "workflow-nudge", + sessionKey: "agent:main:main", + kind: "nudge", + description: "Generic session-owned scheduler cleanup fixture", + }); + api.registerCommand({ + name: "host-hook-fixture", + description: "Exercise host-hook command continuation", + acceptsArgs: true, + handler: async (ctx) => ({ + text: `fixture:${ctx.args ?? "empty"}`, + continueAgent: true, + }), + }); + api.on("agent_turn_prepare", () => ({ + prependContext: "fixture turn context", + })); + api.on("heartbeat_prompt_contribution", () => ({ + appendContext: "fixture heartbeat context", + })); +} + +export function registerTrustedHostHookFixture(api: OpenClawPluginApi) { + registerHostHookFixture(api); + api.registerTrustedToolPolicy({ + id: "budget-policy", + description: "Generic budget/workspace policy gate fixture", + evaluate(event) { + if (event.toolName === "blocked_fixture_tool") { + return { block: true, blockReason: "blocked by fixture policy" }; + } + return undefined; + }, + }); +} diff --git a/src/plugins/contracts/host-hooks.contract.test.ts b/src/plugins/contracts/host-hooks.contract.test.ts new file mode 100644 index 00000000000..51fc36df330 --- /dev/null +++ b/src/plugins/contracts/host-hooks.contract.test.ts @@ -0,0 +1,2258 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + createPluginRegistryFixture, + registerTestPlugin, +} from "../../../test/helpers/plugins/contracts-testkit.js"; +import { loadSessionStore, updateSessionStore, type SessionEntry } from "../../config/sessions.js"; +import { APPROVALS_SCOPE, READ_SCOPE, WRITE_SCOPE } from "../../gateway/operator-scopes.js"; +import { + validatePluginsUiDescriptorsParams, + validateSessionsPluginPatchParams, +} from "../../gateway/protocol/index.js"; +import { buildGatewaySessionRow } from "../../gateway/session-utils.js"; +import { withTempConfig } from "../../gateway/test-temp-config.js"; +import { emitAgentEvent, resetAgentEventsForTest } from "../../infra/agent-events.js"; +import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js"; +import { executePluginCommand, validatePluginCommandDefinition } from "../commands.js"; +import { createHookRunner } from "../hooks.js"; +import { + cleanupReplacedPluginHostRegistry, + clearPluginOwnedSessionState, + runPluginHostCleanup, +} from "../host-hook-cleanup.js"; +import { + clearPluginHostRuntimeState, + getPluginRunContext, + listPluginSessionSchedulerJobs, + setPluginRunContext, +} from "../host-hook-runtime.js"; +import { + drainPluginNextTurnInjections, + enqueuePluginNextTurnInjection, + patchPluginSessionExtension, + projectPluginSessionExtensions, + projectPluginSessionExtensionsSync, +} from "../host-hook-state.js"; +import { buildPluginAgentTurnPrepareContext, isPluginJsonValue } from "../host-hooks.js"; +import { createEmptyPluginRegistry } from "../registry-empty.js"; +import { createPluginRegistry } from "../registry.js"; +import { setActivePluginRegistry } from "../runtime.js"; +import type { PluginRuntime } from "../runtime/types.js"; +import { createPluginRecord } from "../status.test-helpers.js"; +import { runTrustedToolPolicies } from "../trusted-tool-policy.js"; +import { registerHostHookFixture, registerTrustedHostHookFixture } from "./host-hook-fixture.js"; + +async function waitForPluginEventHandlers(): Promise { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +describe("host-hook fixture plugin contract", () => { + afterEach(() => { + setActivePluginRegistry(createEmptyPluginRegistry()); + clearPluginHostRuntimeState(); + resetAgentEventsForTest(); + }); + + it("registers generic SDK seams without Plan Mode business logic", () => { + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "host-hook-fixture", + name: "Host Hook Fixture", + origin: "workspace", + }), + register: registerHostHookFixture, + }); + + expect(registry.registry.sessionExtensions ?? []).toHaveLength(1); + expect(registry.registry.toolMetadata ?? []).toHaveLength(1); + expect(registry.registry.controlUiDescriptors ?? []).toHaveLength(1); + expect(registry.registry.runtimeLifecycles ?? []).toHaveLength(1); + expect(registry.registry.agentEventSubscriptions ?? []).toHaveLength(1); + expect(registry.registry.sessionSchedulerJobs ?? []).toHaveLength(1); + expect(registry.registry.commands.map((entry) => entry.command.name)).toEqual([ + "host-hook-fixture", + ]); + expect(registry.registry.typedHooks.map((entry) => entry.hookName).toSorted()).toEqual([ + "agent_turn_prepare", + "heartbeat_prompt_contribution", + ]); + }); + + it("rejects external plugins from trusted policy and reserved command ownership", () => { + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "external-policy", + name: "External Policy", + origin: "workspace", + }), + register(api) { + api.registerTrustedToolPolicy({ + id: "deny", + description: "Should not be accepted", + evaluate: () => undefined, + }); + api.registerCommand({ + name: "status", + description: "Should not be accepted", + ownership: "reserved", + handler: async () => ({ text: "no" }), + }); + }, + }); + + expect(registry.registry.trustedToolPolicies ?? []).toHaveLength(0); + expect(registry.registry.commands).toHaveLength(0); + expect(registry.registry.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pluginId: "external-policy", + message: expect.stringContaining("only bundled plugins can register trusted tool"), + }), + expect.objectContaining({ + pluginId: "external-policy", + message: expect.stringContaining("only bundled plugins can claim reserved command"), + }), + ]), + ); + }); + + it("rejects reserved command ownership for non-reserved bundled command names", () => { + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "bundled-command", + name: "Bundled Command", + origin: "bundled", + }), + register(api) { + api.registerCommand({ + name: "workflow", + description: "Should not need reserved ownership", + ownership: "reserved", + handler: async () => ({ text: "no" }), + }); + }, + }); + + expect(registry.registry.commands).toHaveLength(0); + expect(registry.registry.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pluginId: "bundled-command", + message: "reserved command ownership requires a reserved command name: workflow", + }), + ]), + ); + }); + + it("lets bundled fixture policies run before normal before_tool_call hooks", async () => { + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "trusted-fixture", + name: "Trusted Fixture", + origin: "bundled", + }), + register: registerTrustedHostHookFixture, + }); + setActivePluginRegistry(registry.registry); + + await expect( + runTrustedToolPolicies( + { toolName: "blocked_fixture_tool", params: {} }, + { toolName: "blocked_fixture_tool" }, + ), + ).resolves.toMatchObject({ + block: true, + blockReason: "blocked by fixture policy", + }); + }); + + it("lets later trusted policy blocks override earlier approval requests", async () => { + const registry = createEmptyPluginRegistry(); + registry.trustedToolPolicies = [ + { + pluginId: "trusted-a", + pluginName: "Trusted A", + source: "test", + policy: { + id: "approval", + description: "approval", + evaluate: () => ({ + requireApproval: { + title: "Review", + description: "Review the call", + }, + }), + }, + }, + { + pluginId: "trusted-b", + pluginName: "Trusted B", + source: "test", + policy: { + id: "block", + description: "block", + evaluate: () => ({ block: true, blockReason: "blocked by later policy" }), + }, + }, + ]; + setActivePluginRegistry(registry); + + await expect( + runTrustedToolPolicies({ toolName: "exec", params: {} }, { toolName: "exec" }), + ).resolves.toEqual({ + block: true, + blockReason: "blocked by later policy", + }); + }); + + it("passes adjusted trusted policy params to later trusted policies", async () => { + const seenParams: Record[] = []; + const registry = createEmptyPluginRegistry(); + registry.trustedToolPolicies = [ + { + pluginId: "trusted-a", + pluginName: "Trusted A", + source: "test", + policy: { + id: "params", + description: "params", + evaluate: () => ({ params: { command: "patched" } }), + }, + }, + { + pluginId: "trusted-b", + pluginName: "Trusted B", + source: "test", + policy: { + id: "inspect", + description: "inspect", + evaluate: (event) => { + seenParams.push(event.params); + return undefined; + }, + }, + }, + ]; + setActivePluginRegistry(registry); + + await expect( + runTrustedToolPolicies( + { toolName: "exec", params: { command: "original" } }, + { toolName: "exec" }, + ), + ).resolves.toEqual({ params: { command: "patched" } }); + expect(seenParams).toEqual([{ command: "patched" }]); + }); + + it("validates plugin-owned JSON values as plain JSON-compatible data", () => { + expect( + isPluginJsonValue({ + state: "waiting", + attempts: 1, + nested: [{ ok: true }, null], + }), + ).toBe(true); + expect(isPluginJsonValue({ value: Number.NaN })).toBe(false); + expect(isPluginJsonValue({ value: undefined })).toBe(false); + expect(isPluginJsonValue(new Date(0))).toBe(false); + expect(isPluginJsonValue(new Map([["state", "waiting"]]))).toBe(false); + expect(isPluginJsonValue({ value: "x".repeat(70 * 1024) })).toBe(false); + }); + + it("rejects non-JSON descriptor schemas before projecting Control UI descriptors", () => { + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "descriptor-fixture", + name: "Descriptor Fixture", + }), + register(api) { + api.registerControlUiDescriptor({ + id: "bad-schema", + surface: "session", + label: "Bad schema", + schema: new Date(0) as never, + }); + }, + }); + + expect(registry.registry.controlUiDescriptors ?? []).toHaveLength(0); + expect(registry.registry.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pluginId: "descriptor-fixture", + message: "control UI descriptor schema must be JSON-compatible: bad-schema", + }), + ]), + ); + }); + + it("projects registered session extensions into gateway session rows", () => { + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "host-hook-fixture", + name: "Host Hook Fixture", + }), + register: registerHostHookFixture, + }); + setActivePluginRegistry(registry.registry); + + const row = buildGatewaySessionRow({ + cfg: config, + storePath: "/tmp/sessions.json", + store: {}, + key: "agent:main:main", + entry: { + sessionId: "session-1", + updatedAt: 1, + pluginExtensions: { + "host-hook-fixture": { + workflow: { state: "waiting" }, + }, + }, + }, + }); + + expect(row.pluginExtensions).toEqual([ + { + pluginId: "host-hook-fixture", + namespace: "workflow", + value: { state: "waiting" }, + }, + ]); + }); + + it("projects sync session extension projectors into gateway rows without exposing raw state", () => { + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "projector-fixture", + name: "Projector Fixture", + }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "Projected workflow state", + project: ({ state }) => { + if (!state || typeof state !== "object" || Array.isArray(state)) { + return undefined; + } + const workflowState = (state as { state?: unknown }).state; + return typeof workflowState === "string" ? { state: workflowState } : undefined; + }, + }); + }, + }); + setActivePluginRegistry(registry.registry); + + const entry: SessionEntry = { + sessionId: "session-1", + updatedAt: 1, + pluginExtensions: { + "projector-fixture": { + workflow: { state: "waiting", privateToken: "secret" }, + }, + }, + }; + expect(projectPluginSessionExtensionsSync({ sessionKey: "agent:main:main", entry })).toEqual([ + { + pluginId: "projector-fixture", + namespace: "workflow", + value: { state: "waiting" }, + }, + ]); + + const row = buildGatewaySessionRow({ + cfg: config, + storePath: "/tmp/sessions.json", + store: {}, + key: "agent:main:main", + entry, + }); + expect(row.pluginExtensions).toEqual([ + { + pluginId: "projector-fixture", + namespace: "workflow", + value: { state: "waiting" }, + }, + ]); + }); + + it("rejects async session extension projectors because gateway rows are synchronous", () => { + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "async-projector-fixture", + name: "Async Projector Fixture", + }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "Async workflow state", + project: (async () => ({ state: "late" })) as unknown as () => undefined, + }); + }, + }); + + expect(registry.registry.sessionExtensions ?? []).toHaveLength(0); + expect(registry.registry.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pluginId: "async-projector-fixture", + message: "session extension projector must be synchronous", + }), + ]), + ); + }); + + it("reports specific diagnostics for malformed session extension callbacks", () => { + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "bad-session-extension-fixture", + name: "Bad Session Extension Fixture", + }), + register(api) { + api.registerSessionExtension({ + namespace: "projector", + description: "Bad projector", + project: "not-a-function" as never, + }); + api.registerSessionExtension({ + namespace: "cleanup", + description: "Bad cleanup", + cleanup: "not-a-function" as never, + }); + }, + }); + + expect(registry.registry.sessionExtensions ?? []).toHaveLength(0); + expect(registry.registry.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pluginId: "bad-session-extension-fixture", + message: "session extension projector must be a function", + }), + expect.objectContaining({ + pluginId: "bad-session-extension-fixture", + message: "session extension cleanup must be a function", + }), + ]), + ); + }); + + it("rejects duplicate runtime lifecycle and agent event subscription ids", () => { + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "duplicate-host-hook-fixture", + name: "Duplicate Host Hook Fixture", + }), + register(api) { + api.registerRuntimeLifecycle({ id: "cleanup", cleanup: () => undefined }); + api.registerRuntimeLifecycle({ id: "cleanup", cleanup: () => undefined }); + api.registerRuntimeLifecycle({ + id: "bad-cleanup", + cleanup: "not-a-function" as never, + }); + api.registerAgentEventSubscription({ + id: "events", + streams: ["tool"], + handle: () => undefined, + }); + api.registerAgentEventSubscription({ + id: "events", + streams: ["error"], + handle: () => undefined, + }); + api.registerAgentEventSubscription({ + id: "missing-handler", + streams: ["tool"], + handle: "not-a-function" as never, + }); + api.registerAgentEventSubscription({ + id: "bad-streams", + streams: { length: 1, 0: "tool" } as never, + handle: () => undefined, + }); + api.registerSessionSchedulerJob({ + id: "bad-scheduler-cleanup", + sessionKey: "agent:main:main", + kind: "monitor", + cleanup: "not-a-function" as never, + }); + }, + }); + + expect(registry.registry.runtimeLifecycles ?? []).toHaveLength(1); + expect(registry.registry.agentEventSubscriptions ?? []).toHaveLength(1); + expect(registry.registry.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pluginId: "duplicate-host-hook-fixture", + message: "runtime lifecycle already registered: cleanup", + }), + expect.objectContaining({ + pluginId: "duplicate-host-hook-fixture", + message: "runtime lifecycle cleanup must be a function: bad-cleanup", + }), + expect.objectContaining({ + pluginId: "duplicate-host-hook-fixture", + message: "agent event subscription already registered: events", + }), + expect.objectContaining({ + pluginId: "duplicate-host-hook-fixture", + message: "agent event subscription registration requires id and handle", + }), + expect.objectContaining({ + pluginId: "duplicate-host-hook-fixture", + message: "agent event subscription streams must be an array of strings: bad-streams", + }), + expect.objectContaining({ + pluginId: "duplicate-host-hook-fixture", + message: "session scheduler job cleanup must be a function: bad-scheduler-cleanup", + }), + ]), + ); + }); + + it("defensively ignores promise-like session projections from untyped plugins", async () => { + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "promise-projector-fixture", + name: "Promise Projector Fixture", + }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "Promise workflow state", + project: (() => + Promise.reject( + new Error("projectors must be synchronous"), + )) as unknown as () => undefined, + }); + }, + }); + setActivePluginRegistry(registry.registry); + const entry: SessionEntry = { + sessionId: "session-1", + updatedAt: 1, + pluginExtensions: { + "promise-projector-fixture": { + workflow: { state: "waiting" }, + }, + }, + }; + + expect(projectPluginSessionExtensionsSync({ sessionKey: "agent:main:main", entry })).toEqual( + [], + ); + await expect( + projectPluginSessionExtensions({ sessionKey: "agent:main:main", entry }), + ).resolves.toEqual([]); + }); + + it("skips throwing session extension projectors without losing other projections", () => { + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "throwing-projector-fixture", + name: "Throwing Projector Fixture", + }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "Throwing workflow state", + project: () => { + throw new Error("projection failed"); + }, + }); + }, + }); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "healthy-projector-fixture", + name: "Healthy Projector Fixture", + }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "Healthy workflow state", + project: ({ state }) => state, + }); + }, + }); + setActivePluginRegistry(registry.registry); + const entry: SessionEntry = { + sessionId: "session-1", + updatedAt: 1, + pluginExtensions: { + "throwing-projector-fixture": { + workflow: { state: "hidden" }, + }, + "healthy-projector-fixture": { + workflow: { state: "visible" }, + }, + }, + }; + + expect(projectPluginSessionExtensionsSync({ sessionKey: "agent:main:main", entry })).toEqual([ + { + pluginId: "healthy-projector-fixture", + namespace: "workflow", + value: { state: "visible" }, + }, + ]); + const row = buildGatewaySessionRow({ + cfg: config, + storePath: "/tmp/sessions.json", + store: {}, + key: "agent:main:main", + entry, + }); + expect(row.pluginExtensions).toEqual([ + { + pluginId: "healthy-projector-fixture", + namespace: "workflow", + value: { state: "visible" }, + }, + ]); + }); + + it("requires explicit unset to remove plugin session extension state", async () => { + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "patch-fixture", + name: "Patch Fixture", + }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "Patch workflow state", + }); + }, + }); + setActivePluginRegistry(registry.registry); + + const stateDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-hooks-patch-"), + ); + const storePath = path.join(stateDir, "sessions.json"); + const tempConfig = { + session: { store: storePath }, + }; + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + try { + process.env.OPENCLAW_STATE_DIR = stateDir; + await withTempConfig({ + cfg: tempConfig, + run: async () => { + await updateSessionStore(storePath, (store) => { + store["agent:main:main"] = { + sessionId: "session-1", + updatedAt: Date.now(), + pluginExtensions: { + "patch-fixture": { workflow: { state: "waiting" } }, + }, + }; + return undefined; + }); + + await expect( + patchPluginSessionExtension({ + cfg: tempConfig, + sessionKey: "agent:main:main", + pluginId: "patch-fixture", + namespace: "workflow", + }), + ).resolves.toEqual({ + ok: false, + error: "plugin session extension value is required unless unset is true", + }); + expect( + loadSessionStore(storePath)["agent:main:main"]?.pluginExtensions?.["patch-fixture"] + ?.workflow, + ).toEqual({ state: "waiting" }); + + await expect( + patchPluginSessionExtension({ + cfg: tempConfig, + sessionKey: "agent:main:main", + pluginId: "patch-fixture", + namespace: "workflow", + value: { state: "ambiguous" }, + unset: true, + }), + ).resolves.toEqual({ + ok: false, + error: "plugin session extension cannot specify both unset and value", + }); + expect( + loadSessionStore(storePath)["agent:main:main"]?.pluginExtensions?.["patch-fixture"] + ?.workflow, + ).toEqual({ state: "waiting" }); + + await expect( + patchPluginSessionExtension({ + cfg: tempConfig, + sessionKey: "agent:main:main", + pluginId: "patch-fixture", + namespace: "workflow", + value: { state: "approved" }, + }), + ).resolves.toEqual({ + ok: true, + key: "agent:main:main", + value: { state: "approved" }, + }); + + await expect( + patchPluginSessionExtension({ + cfg: tempConfig, + sessionKey: "agent:main:main", + pluginId: "patch-fixture", + namespace: "workflow", + unset: true, + }), + ).resolves.toEqual({ + ok: true, + key: "agent:main:main", + value: undefined, + }); + expect(loadSessionStore(storePath)["agent:main:main"]?.pluginExtensions).toBeUndefined(); + }, + }); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); + + it("models queued next-turn injections and agent_turn_prepare as one prompt context", async () => { + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "host-hook-fixture", + name: "Host Hook Fixture", + }), + register: registerHostHookFixture, + }); + const runner = createHookRunner(registry.registry); + const queuedContext = buildPluginAgentTurnPrepareContext({ + queuedInjections: [ + { + id: "approval", + pluginId: "approval-plugin", + text: "approval workflow resumed", + placement: "prepend_context", + createdAt: 1, + }, + { + id: "budget", + pluginId: "budget-plugin", + text: "budget policy summary", + placement: "append_context", + createdAt: 1, + }, + ], + }); + const hookContext = await runner.runAgentTurnPrepare( + { + prompt: "continue", + messages: [], + queuedInjections: [], + }, + { sessionKey: "agent:main:main" }, + ); + + expect( + [queuedContext.prependContext, queuedContext.appendContext, hookContext?.prependContext] + .filter(Boolean) + .join("\n\n"), + ).toContain("approval workflow resumed"); + expect(hookContext?.prependContext).toBe("fixture turn context"); + }); + + it("skips malformed persisted next-turn injection records during prompt assembly", () => { + const queuedContext = buildPluginAgentTurnPrepareContext({ + queuedInjections: [ + { + id: "bad-text", + pluginId: "approval-plugin", + text: 123, + placement: "prepend_context", + createdAt: 1, + } as never, + { + id: "bad-placement", + pluginId: "approval-plugin", + text: "wrong placement", + placement: "middle_context", + createdAt: 1, + } as never, + { + id: "valid", + pluginId: "approval-plugin", + text: " approval workflow resumed ", + placement: "append_context", + createdAt: 1, + }, + ], + }); + + expect(queuedContext).toEqual({ appendContext: "approval workflow resumed" }); + }); + + it("rejects malformed next-turn injection input before persisting records", async () => { + await expect( + enqueuePluginNextTurnInjection({ + cfg: {}, + pluginId: "approval-fixture", + injection: { + sessionKey: "agent:main:main", + text: "invalid placement", + placement: "middle_context", + } as never, + }), + ).resolves.toEqual({ enqueued: false, id: "", sessionKey: "agent:main:main" }); + + await expect( + enqueuePluginNextTurnInjection({ + cfg: {}, + pluginId: "approval-fixture", + injection: { + sessionKey: "agent:main:main", + text: "invalid ttl", + ttlMs: Number.POSITIVE_INFINITY, + }, + }), + ).resolves.toEqual({ enqueued: false, id: "", sessionKey: "agent:main:main" }); + + await expect( + enqueuePluginNextTurnInjection({ + cfg: {}, + pluginId: "approval-fixture", + injection: { + sessionKey: "agent:main:main", + text: "negative ttl", + ttlMs: -1, + }, + }), + ).resolves.toEqual({ enqueued: false, id: "", sessionKey: "agent:main:main" }); + }); + + it("reports duplicate next-turn injections as not newly enqueued", async () => { + const stateDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-hooks-injection-"), + ); + const storePath = path.join(stateDir, "sessions.json"); + const tempConfig = { + session: { store: storePath }, + }; + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + try { + process.env.OPENCLAW_STATE_DIR = stateDir; + await withTempConfig({ + cfg: tempConfig, + run: async () => { + await updateSessionStore(storePath, (store) => { + store["agent:main:main"] = { + sessionId: "session-1", + updatedAt: Date.now(), + }; + return undefined; + }); + const now = Date.now(); + + const first = await enqueuePluginNextTurnInjection({ + cfg: tempConfig, + pluginId: "approval-fixture", + injection: { + sessionKey: "agent:main:main", + text: "resume approval workflow", + placement: "prepend_context", + idempotencyKey: "approval:resume", + }, + now, + }); + const duplicate = await enqueuePluginNextTurnInjection({ + cfg: tempConfig, + pluginId: "approval-fixture", + injection: { + sessionKey: "agent:main:main", + text: "resume approval workflow again", + placement: "prepend_context", + idempotencyKey: "approval:resume", + }, + now: now + 1, + }); + + expect(first.enqueued).toBe(true); + expect(duplicate).toEqual({ + enqueued: false, + id: first.id, + sessionKey: "agent:main:main", + }); + const stored = loadSessionStore(storePath, { skipCache: true }); + expect( + stored["agent:main:main"]?.pluginNextTurnInjections?.["approval-fixture"], + ).toHaveLength(1); + }, + }); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); + + it("suppresses stale next-turn injections from plugins that are no longer loaded", async () => { + const registry = createEmptyPluginRegistry(); + registry.plugins.push( + createPluginRecord({ + id: "active-injector", + name: "Active Injector", + status: "loaded", + }), + createPluginRecord({ + id: "disabled-injector", + name: "Disabled Injector", + status: "disabled", + }), + createPluginRecord({ + id: "policy-blocked-injector", + name: "Policy Blocked Injector", + status: "loaded", + }), + ); + setActivePluginRegistry(registry); + const stateDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-hooks-stale-"), + ); + const storePath = path.join(stateDir, "sessions.json"); + const tempConfig = { + session: { store: storePath }, + plugins: { + entries: { + "policy-blocked-injector": { + hooks: { allowPromptInjection: false }, + }, + }, + }, + }; + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + try { + process.env.OPENCLAW_STATE_DIR = stateDir; + await withTempConfig({ + cfg: tempConfig, + run: async () => { + await updateSessionStore(storePath, (store) => { + store["agent:main:main"] = { + sessionId: "session-1", + updatedAt: Date.now(), + pluginNextTurnInjections: { + "active-injector": [ + { + id: "active", + pluginId: "active-injector", + text: "active prompt contribution", + placement: "append_context", + createdAt: 1, + }, + ], + "disabled-injector": [ + { + id: "stale", + pluginId: "disabled-injector", + text: "stale prompt contribution", + placement: "prepend_context", + createdAt: 1, + }, + ], + "policy-blocked-injector": [ + { + id: "policy-blocked", + pluginId: "policy-blocked-injector", + text: "policy blocked prompt contribution", + placement: "prepend_context", + createdAt: 1, + }, + ], + }, + }; + return undefined; + }); + + await expect( + drainPluginNextTurnInjections({ + cfg: tempConfig, + sessionKey: "agent:main:main", + now: 2, + }), + ).resolves.toEqual([ + expect.objectContaining({ + id: "active", + pluginId: "active-injector", + text: "active prompt contribution", + }), + ]); + const stored = loadSessionStore(storePath, { skipCache: true }); + expect(stored["agent:main:main"]?.pluginNextTurnInjections).toBeUndefined(); + }, + }); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); + + it("preserves global enqueue order when draining live next-turn injections", async () => { + const registry = createEmptyPluginRegistry(); + registry.plugins.push( + createPluginRecord({ + id: "injector-a", + name: "Injector A", + status: "loaded", + }), + createPluginRecord({ + id: "injector-b", + name: "Injector B", + status: "loaded", + }), + ); + setActivePluginRegistry(registry); + const stateDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-hooks-order-"), + ); + const storePath = path.join(stateDir, "sessions.json"); + const tempConfig = { + session: { store: storePath }, + }; + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + try { + process.env.OPENCLAW_STATE_DIR = stateDir; + await withTempConfig({ + cfg: tempConfig, + run: async () => { + await updateSessionStore(storePath, (store) => { + store["agent:main:main"] = { + sessionId: "session-1", + updatedAt: Date.now(), + pluginNextTurnInjections: { + "injector-a": [ + { + id: "a1", + pluginId: "injector-a", + text: "first", + placement: "append_context", + createdAt: 1, + }, + { + id: "a2", + pluginId: "injector-a", + text: "third", + placement: "append_context", + createdAt: 3, + }, + ], + "injector-b": [ + { + id: "b1", + pluginId: "injector-b", + text: "second", + placement: "append_context", + createdAt: 2, + }, + ], + }, + }; + return undefined; + }); + + await expect( + drainPluginNextTurnInjections({ + cfg: tempConfig, + sessionKey: "agent:main:main", + now: 4, + }), + ).resolves.toEqual([ + expect.objectContaining({ id: "a1", text: "first" }), + expect.objectContaining({ id: "b1", text: "second" }), + expect.objectContaining({ id: "a2", text: "third" }), + ]); + }, + }); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); + + it("validates gateway protocol envelopes for plugin patch and UI descriptors", () => { + expect( + validateSessionsPluginPatchParams({ + key: "agent:main:main", + pluginId: "approval-plugin", + namespace: "workflow", + value: { state: "waiting" }, + }), + ).toBe(true); + expect( + validateSessionsPluginPatchParams({ + key: "agent:main:main", + pluginId: "approval-plugin", + namespace: "workflow", + value: { state: "waiting" }, + accidentalPlanModeRootField: true, + }), + ).toBe(false); + expect(validatePluginsUiDescriptorsParams({})).toBe(true); + expect(validatePluginsUiDescriptorsParams({ pluginId: "host-hook-fixture" })).toBe(false); + }); + + it("enforces command requiredScopes for gateway clients while preserving text command continuations", async () => { + const handlerCalls: string[] = []; + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "approval-command-fixture", + name: "Approval Command Fixture", + }), + register(api) { + api.registerCommand({ + name: "approval-fixture", + description: "Continue the agent after approval.", + requiredScopes: [APPROVALS_SCOPE], + acceptsArgs: true, + handler: async (ctx) => { + handlerCalls.push(ctx.args ?? ""); + return { text: "approval queued", continueAgent: true }; + }, + }); + }, + }); + const registration = registry.registry.commands[0]; + expect(registration).toBeTruthy(); + const command = { + ...registration.command, + pluginId: registration.pluginId, + pluginName: registration.pluginName, + pluginRoot: registration.rootDir, + }; + expect( + validatePluginCommandDefinition({ + name: "invalid-scopes-fixture", + description: "Invalid scopes.", + requiredScopes: "operator.approvals" as never, + handler: () => ({ text: "unused" }), + }), + ).toBe("Command requiredScopes must be an array of operator scopes"); + expect( + validatePluginCommandDefinition({ + name: "unknown-scopes-fixture", + description: "Unknown scopes.", + requiredScopes: ["operator.unknown" as never], + handler: () => ({ text: "unused" }), + }), + ).toBe("Command requiredScopes contains unknown operator scope: operator.unknown"); + + await expect( + executePluginCommand({ + command, + args: "resume-text", + senderId: "owner", + channel: "whatsapp", + isAuthorizedSender: true, + sessionKey: "agent:main:main", + commandBody: "/approval-fixture resume-text", + config, + }), + ).resolves.toEqual({ text: "approval queued", continueAgent: true }); + expect(handlerCalls).toEqual(["resume-text"]); + + await expect( + executePluginCommand({ + command, + args: "resume", + senderId: "owner", + channel: "whatsapp", + isAuthorizedSender: true, + gatewayClientScopes: [READ_SCOPE, WRITE_SCOPE], + sessionKey: "agent:main:main", + commandBody: "/approval-fixture resume", + config, + }), + ).resolves.toEqual({ + text: `⚠️ This command requires gateway scope: ${APPROVALS_SCOPE}.`, + }); + expect(handlerCalls).toEqual(["resume-text"]); + + await expect( + executePluginCommand({ + command, + args: "resume", + senderId: "owner", + channel: "whatsapp", + isAuthorizedSender: true, + gatewayClientScopes: [APPROVALS_SCOPE], + sessionKey: "agent:main:main", + commandBody: "/approval-fixture resume", + config, + }), + ).resolves.toEqual({ text: "approval queued", continueAgent: true }); + expect(handlerCalls).toEqual(["resume-text", "resume"]); + }); + + it("dispatches sanitized agent events and clears plugin run context on run end", async () => { + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "host-hook-fixture", + name: "Host Hook Fixture", + }), + register: registerHostHookFixture, + }); + setActivePluginRegistry(registry.registry); + + emitAgentEvent({ + runId: "run-1", + stream: "tool", + data: { name: "approval_fixture_tool" }, + }); + await Promise.resolve(); + + expect( + getPluginRunContext({ + pluginId: "host-hook-fixture", + get: { runId: "run-1", namespace: "lastToolEvent" }, + }), + ).toEqual({ runId: "run-1", seen: true }); + + emitAgentEvent({ + runId: "run-1", + stream: "lifecycle", + data: { phase: "end" }, + }); + await waitForPluginEventHandlers(); + + expect( + getPluginRunContext({ + pluginId: "host-hook-fixture", + get: { runId: "run-1", namespace: "lastToolEvent" }, + }), + ).toBeUndefined(); + }); + + it("clears run context on terminal events even when no plugin subscribes to agent events", async () => { + setActivePluginRegistry(createEmptyPluginRegistry()); + expect( + setPluginRunContext({ + pluginId: "context-only-plugin", + patch: { runId: "run-no-subscribers", namespace: "state", value: { ok: true } }, + }), + ).toBe(true); + + emitAgentEvent({ + runId: "run-no-subscribers", + stream: "lifecycle", + data: { phase: "end" }, + }); + await waitForPluginEventHandlers(); + + expect( + getPluginRunContext({ + pluginId: "context-only-plugin", + get: { runId: "run-no-subscribers", namespace: "state" }, + }), + ).toBeUndefined(); + }); + + it("does not let delayed non-terminal subscriptions resurrect closed run context", async () => { + let releaseToolHandler: (() => void) | undefined; + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "delayed-subscription", + name: "Delayed Subscription", + }), + register(api) { + api.registerAgentEventSubscription({ + id: "delayed", + streams: ["tool"], + async handle(_event, ctx) { + await new Promise((resolve) => { + releaseToolHandler = resolve; + }); + ctx.setRunContext("late", { resurrected: true }); + }, + }); + }, + }); + setActivePluginRegistry(registry.registry); + + emitAgentEvent({ + runId: "run-delayed-subscription", + stream: "tool", + data: { name: "approval_fixture_tool" }, + }); + await Promise.resolve(); + + emitAgentEvent({ + runId: "run-delayed-subscription", + stream: "lifecycle", + data: { phase: "end" }, + }); + releaseToolHandler?.(); + await waitForPluginEventHandlers(); + + expect( + getPluginRunContext({ + pluginId: "delayed-subscription", + get: { runId: "run-delayed-subscription", namespace: "late" }, + }), + ).toBeUndefined(); + }); + + it("continues agent event dispatch and terminal cleanup when one subscription throws", async () => { + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "throwing-subscription", + name: "Throwing Subscription", + }), + register(api) { + api.registerAgentEventSubscription({ + id: "throws", + streams: ["tool"], + handle() { + throw new Error("subscription failed"); + }, + }); + }, + }); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "healthy-subscription", + name: "Healthy Subscription", + }), + register(api) { + api.registerAgentEventSubscription({ + id: "records", + streams: ["tool"], + handle(event, ctx) { + ctx.setRunContext("seen", { runId: event.runId }); + }, + }); + }, + }); + setActivePluginRegistry(registry.registry); + + emitAgentEvent({ + runId: "run-throws", + stream: "tool", + data: { name: "approval_fixture_tool" }, + }); + await Promise.resolve(); + + expect( + getPluginRunContext({ + pluginId: "healthy-subscription", + get: { runId: "run-throws", namespace: "seen" }, + }), + ).toEqual({ runId: "run-throws" }); + + emitAgentEvent({ + runId: "run-throws", + stream: "lifecycle", + data: { phase: "end" }, + }); + await waitForPluginEventHandlers(); + + expect( + getPluginRunContext({ + pluginId: "healthy-subscription", + get: { runId: "run-throws", namespace: "seen" }, + }), + ).toBeUndefined(); + }); + + it("preserves run context until async terminal event subscriptions settle", async () => { + let releaseTerminalHandler: (() => void) | undefined; + let terminalHandlerSawContext: unknown; + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "async-terminal-subscription", + name: "Async Terminal Subscription", + }), + register(api) { + api.registerAgentEventSubscription({ + id: "records", + streams: ["tool", "lifecycle"], + async handle(event, ctx) { + if (event.stream === "tool") { + ctx.setRunContext("seen", { runId: event.runId }); + return; + } + if (event.data?.phase !== "end") { + return; + } + await new Promise((resolve) => { + releaseTerminalHandler = resolve; + }); + terminalHandlerSawContext = ctx.getRunContext("seen"); + }, + }); + }, + }); + setActivePluginRegistry(registry.registry); + + emitAgentEvent({ + runId: "run-async-terminal", + stream: "tool", + data: { name: "approval_fixture_tool" }, + }); + await Promise.resolve(); + + emitAgentEvent({ + runId: "run-async-terminal", + stream: "lifecycle", + data: { phase: "end" }, + }); + await Promise.resolve(); + + expect( + getPluginRunContext({ + pluginId: "async-terminal-subscription", + get: { runId: "run-async-terminal", namespace: "seen" }, + }), + ).toEqual({ runId: "run-async-terminal" }); + + releaseTerminalHandler?.(); + await waitForPluginEventHandlers(); + + expect(terminalHandlerSawContext).toEqual({ runId: "run-async-terminal" }); + expect( + getPluginRunContext({ + pluginId: "async-terminal-subscription", + get: { runId: "run-async-terminal", namespace: "seen" }, + }), + ).toBeUndefined(); + }); + + it("covers the non-Plan plugin archetypes promised by the host-hook fixture", () => { + const archetypes = [ + { + name: "approval workflow", + seams: [ + "session extension", + "command continuation", + "next-turn injection", + "UI descriptor", + ], + }, + { + name: "budget/workspace policy gate", + seams: ["trusted tool policy", "tool metadata", "session projection"], + }, + { + name: "background lifecycle monitor", + seams: ["agent event subscription", "scheduler cleanup", "heartbeat prompt contribution"], + }, + ]; + + expect(archetypes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "approval workflow" }), + expect.objectContaining({ name: "budget/workspace policy gate" }), + expect.objectContaining({ name: "background lifecycle monitor" }), + ]), + ); + expect(archetypes.flatMap((entry) => entry.seams)).toEqual( + expect.arrayContaining([ + "session extension", + "trusted tool policy", + "agent event subscription", + "scheduler cleanup", + ]), + ); + }); + + it("proves every #71676 Plan Mode entry-point class has a generic host seam", () => { + const parityMap = [ + ["session state + sessions.patch", "session extensions + sessions.pluginPatch"], + [ + "pending injections + approval resumes", + "durable next-turn injections + agent_turn_prepare", + ], + ["mutation gates around tools", "trusted tool policy before before_tool_call"], + ["slash/native command continuations", "requiredScopes + reserved ownership + continueAgent"], + ["Control UI mode/cards/status", "Control UI descriptor projection"], + [ + "plan snapshots, nudges, subagent follow-ups, heartbeat", + "agent events + run context + scheduler cleanup + heartbeat contribution", + ], + ["tool catalog display metadata", "plugin tool metadata projection"], + ["disable/reset/delete/restart cleanup", "runtime lifecycle cleanup"], + ]; + + expect(parityMap).toHaveLength(8); + for (const [entryPoint, seam] of parityMap) { + expect(entryPoint).toBeTruthy(); + expect(seam).toBeTruthy(); + expect(seam).not.toContain("Plan Mode"); + } + }); + + it("cleans plugin-owned session state and lifecycle resources on reset/disable", async () => { + const cleanupEvents: string[] = []; + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "cleanup-fixture", + name: "Cleanup Fixture", + }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "cleanup test", + cleanup: ({ reason, sessionKey }) => { + cleanupEvents.push(`session:${reason}:${sessionKey ?? ""}`); + }, + }); + api.registerRuntimeLifecycle({ + id: "monitor", + cleanup: ({ reason, sessionKey }) => { + cleanupEvents.push(`runtime:${reason}:${sessionKey ?? ""}`); + }, + }); + api.registerSessionSchedulerJob({ + id: "nudge", + sessionKey: "agent:main:main", + kind: "monitor", + cleanup: ({ reason, sessionKey }) => { + cleanupEvents.push(`scheduler:${reason}:${sessionKey}`); + }, + }); + }, + }); + + const entry: SessionEntry = { + sessionId: "session-1", + updatedAt: 1, + pluginExtensions: { + "cleanup-fixture": { workflow: { state: "waiting" } }, + "other-plugin": { workflow: { state: "keep" } }, + }, + pluginNextTurnInjections: { + "cleanup-fixture": [ + { + id: "resume", + pluginId: "cleanup-fixture", + text: "resume", + placement: "prepend_context" as const, + createdAt: 1, + }, + ], + "other-plugin": [ + { + id: "keep", + pluginId: "other-plugin", + text: "keep", + placement: "append_context" as const, + createdAt: 1, + }, + ], + }, + }; + clearPluginOwnedSessionState(entry, "cleanup-fixture"); + expect(entry.pluginExtensions).toEqual({ + "other-plugin": { workflow: { state: "keep" } }, + }); + expect(entry.pluginNextTurnInjections).toEqual({ + "other-plugin": [ + { + id: "keep", + pluginId: "other-plugin", + text: "keep", + placement: "append_context", + createdAt: 1, + }, + ], + }); + + const stateDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-hooks-state-"), + ); + const tempConfig = { + session: { store: path.join(stateDir, "sessions.json") }, + }; + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + try { + process.env.OPENCLAW_STATE_DIR = stateDir; + await withTempConfig({ + cfg: tempConfig, + run: async () => { + await runPluginHostCleanup({ + cfg: tempConfig, + registry: registry.registry, + pluginId: "cleanup-fixture", + reason: "reset", + sessionKey: "agent:main:main", + }); + await cleanupReplacedPluginHostRegistry({ + cfg: tempConfig, + previousRegistry: registry.registry, + nextRegistry: createEmptyPluginRegistry(), + }); + }, + }); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + await fs.rm(stateDir, { recursive: true, force: true }); + } + + expect(cleanupEvents).toEqual([ + "session:reset:agent:main:main", + "runtime:reset:agent:main:main", + "scheduler:reset:agent:main:main", + "session:disable:", + "runtime:disable:", + ]); + expect(listPluginSessionSchedulerJobs("cleanup-fixture")).toEqual([]); + }); + + it("keeps scheduler job records when cleanup fails so cleanup can retry", async () => { + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "cleanup-failure-fixture", + name: "Cleanup Failure Fixture", + }), + register(api) { + api.registerSessionSchedulerJob({ + id: "retryable-job", + sessionKey: "agent:main:main", + kind: "monitor", + cleanup: () => { + throw new Error("cleanup failed"); + }, + }); + }, + }); + + await expect( + runPluginHostCleanup({ + cfg: config, + registry: registry.registry, + pluginId: "cleanup-failure-fixture", + reason: "disable", + }), + ).resolves.toMatchObject({ + failures: [ + expect.objectContaining({ + pluginId: "cleanup-failure-fixture", + hookId: "scheduler:retryable-job", + }), + ], + }); + expect(listPluginSessionSchedulerJobs("cleanup-failure-fixture")).toEqual([ + { + id: "retryable-job", + pluginId: "cleanup-failure-fixture", + sessionKey: "agent:main:main", + kind: "monitor", + }, + ]); + }); + + it("preserves restarted scheduler jobs while cleaning the replaced registry", async () => { + const cleanupEvents: string[] = []; + const previous = createEmptyPluginRegistry(); + previous.plugins.push( + createPluginRecord({ + id: "restart-fixture", + name: "Restart Fixture", + status: "loaded", + }), + ); + previous.sessionSchedulerJobs = [ + { + pluginId: "restart-fixture", + pluginName: "Restart Fixture", + job: { + id: "shared-job", + sessionKey: "agent:main:main", + kind: "monitor", + cleanup: ({ reason, jobId }) => { + cleanupEvents.push(`${reason}:${jobId}`); + }, + }, + source: "/virtual/restart-fixture/index.ts", + rootDir: "/virtual/restart-fixture", + }, + ]; + const next = createEmptyPluginRegistry(); + next.plugins.push( + createPluginRecord({ + id: "restart-fixture", + name: "Restart Fixture", + status: "loaded", + }), + ); + next.sessionSchedulerJobs = [ + { + pluginId: "restart-fixture", + pluginName: "Restart Fixture", + job: { + id: "shared-job", + sessionKey: "agent:main:main", + kind: "monitor", + cleanup: () => undefined, + }, + source: "/virtual/restart-fixture/index.ts", + rootDir: "/virtual/restart-fixture", + }, + ]; + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "restart-fixture", + name: "Restart Fixture", + }), + register(api) { + api.registerSessionSchedulerJob({ + id: "shared-job", + sessionKey: "agent:main:main", + kind: "monitor", + }); + }, + }); + + await expect( + cleanupReplacedPluginHostRegistry({ + cfg: config, + previousRegistry: previous, + nextRegistry: next, + }), + ).resolves.toMatchObject({ failures: [] }); + expect(cleanupEvents).toEqual([]); + expect(listPluginSessionSchedulerJobs("restart-fixture")).toEqual([ + { + id: "shared-job", + pluginId: "restart-fixture", + sessionKey: "agent:main:main", + kind: "monitor", + }, + ]); + }); + + it("does not let stale scheduler cleanup delete a newer job generation", async () => { + let releaseCleanup: (() => void) | undefined; + let markCleanupStarted!: () => void; + const cleanupStartedPromise = new Promise((resolve) => { + markCleanupStarted = resolve; + }); + const previousFixture = createPluginRegistryFixture(); + registerTestPlugin({ + registry: previousFixture.registry, + config: previousFixture.config, + record: createPluginRecord({ + id: "scheduler-race", + name: "Scheduler Race", + }), + register(api) { + api.registerSessionSchedulerJob({ + id: "shared-job", + sessionKey: "agent:main:main", + kind: "monitor", + cleanup: async () => { + markCleanupStarted(); + await new Promise((resolve) => { + releaseCleanup = resolve; + }); + }, + }); + }, + }); + + const cleanupPromise = cleanupReplacedPluginHostRegistry({ + cfg: previousFixture.config, + previousRegistry: previousFixture.registry.registry, + nextRegistry: createEmptyPluginRegistry(), + }); + await cleanupStartedPromise; + + const replacementFixture = createPluginRegistryFixture(); + registerTestPlugin({ + registry: replacementFixture.registry, + config: replacementFixture.config, + record: createPluginRecord({ + id: "scheduler-race", + name: "Scheduler Race", + }), + register(api) { + api.registerSessionSchedulerJob({ + id: "shared-job", + sessionKey: "agent:main:main", + kind: "monitor", + }); + }, + }); + + releaseCleanup?.(); + await expect(cleanupPromise).resolves.toMatchObject({ failures: [] }); + expect(listPluginSessionSchedulerJobs("scheduler-race")).toEqual([ + { + id: "shared-job", + pluginId: "scheduler-race", + sessionKey: "agent:main:main", + kind: "monitor", + }, + ]); + }); + + it("does not register scheduler jobs globally during non-activating registry loads", () => { + const registry = createPluginRegistry({ + logger: { + info() {}, + warn() {}, + error() {}, + debug() {}, + }, + runtime: {} as PluginRuntime, + activateGlobalSideEffects: false, + }); + const config = {}; + let handle: + | { + id: string; + pluginId: string; + sessionKey: string; + kind: string; + } + | undefined; + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "snapshot-fixture", + name: "Snapshot Fixture", + }), + register(api) { + handle = api.registerSessionSchedulerJob({ + id: "snapshot-job", + sessionKey: "agent:main:main", + kind: "monitor", + }); + }, + }); + + expect(handle).toEqual({ + id: "snapshot-job", + pluginId: "snapshot-fixture", + sessionKey: "agent:main:main", + kind: "monitor", + }); + expect(registry.registry.sessionSchedulerJobs).toEqual([ + expect.objectContaining({ + pluginId: "snapshot-fixture", + job: expect.objectContaining({ + id: "snapshot-job", + sessionKey: "agent:main:main", + kind: "monitor", + }), + }), + ]); + expect(listPluginSessionSchedulerJobs("snapshot-fixture")).toEqual([]); + }); + + it("removes persistent plugin-owned session state and pending injections during cleanup", async () => { + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "cleanup-fixture", + name: "Cleanup Fixture", + }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "cleanup test", + }); + }, + }); + + const stateDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-hooks-store-"), + ); + const storePath = path.join(stateDir, "sessions.json"); + const tempConfig = { + session: { store: storePath }, + }; + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + try { + process.env.OPENCLAW_STATE_DIR = stateDir; + await withTempConfig({ + cfg: tempConfig, + run: async () => { + await updateSessionStore(storePath, (store) => { + store["agent:main:main"] = { + sessionId: "session-1", + updatedAt: Date.now(), + pluginExtensions: { + "cleanup-fixture": { workflow: { state: "waiting" } }, + "other-plugin": { workflow: { state: "keep" } }, + }, + pluginNextTurnInjections: { + "cleanup-fixture": [ + { + id: "resume", + pluginId: "cleanup-fixture", + text: "resume", + placement: "prepend_context", + createdAt: 1, + }, + ], + "other-plugin": [ + { + id: "keep", + pluginId: "other-plugin", + text: "keep", + placement: "append_context", + createdAt: 1, + }, + ], + }, + }; + return undefined; + }); + + await expect( + runPluginHostCleanup({ + cfg: tempConfig, + registry: registry.registry, + pluginId: "cleanup-fixture", + reason: "disable", + }), + ).resolves.toMatchObject({ failures: [] }); + + const stored = loadSessionStore(storePath, { skipCache: true }); + expect(stored["agent:main:main"]).toEqual( + expect.objectContaining({ + pluginExtensions: { + "other-plugin": { workflow: { state: "keep" } }, + }, + pluginNextTurnInjections: { + "other-plugin": [ + { + id: "keep", + pluginId: "other-plugin", + text: "keep", + placement: "append_context", + createdAt: 1, + }, + ], + }, + }), + ); + }, + }); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); + + it("does not clear unrelated run context during session-scoped cleanup", async () => { + const registry = createEmptyPluginRegistry(); + expect( + setPluginRunContext({ + pluginId: "plugin-a", + patch: { runId: "run-a", namespace: "state", value: { keep: "a" } }, + }), + ).toBe(true); + expect( + setPluginRunContext({ + pluginId: "plugin-b", + patch: { runId: "run-b", namespace: "state", value: { keep: "b" } }, + }), + ).toBe(true); + + const stateDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-hooks-run-context-"), + ); + const tempConfig = { + session: { store: path.join(stateDir, "sessions.json") }, + }; + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + try { + process.env.OPENCLAW_STATE_DIR = stateDir; + await withTempConfig({ + cfg: tempConfig, + run: async () => { + await runPluginHostCleanup({ + cfg: tempConfig, + registry, + reason: "reset", + sessionKey: "agent:main:main", + }); + }, + }); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + await fs.rm(stateDir, { recursive: true, force: true }); + } + + expect( + getPluginRunContext({ + pluginId: "plugin-a", + get: { runId: "run-a", namespace: "state" }, + }), + ).toEqual({ keep: "a" }); + expect( + getPluginRunContext({ + pluginId: "plugin-b", + get: { runId: "run-b", namespace: "state" }, + }), + ).toEqual({ keep: "b" }); + }); + + it("preserves durable plugin session state during plugin restart cleanup", async () => { + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "restart-state-fixture", + name: "Restart State Fixture", + }), + register(api) { + api.registerSessionExtension({ + namespace: "workflow", + description: "restart state test", + }); + }, + }); + + const stateDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-hooks-restart-state-"), + ); + const storePath = path.join(stateDir, "sessions.json"); + const tempConfig = { + session: { store: storePath }, + }; + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + try { + process.env.OPENCLAW_STATE_DIR = stateDir; + await withTempConfig({ + cfg: tempConfig, + run: async () => { + await updateSessionStore(storePath, (store) => { + store["agent:main:main"] = { + sessionId: "session-1", + updatedAt: Date.now(), + pluginExtensions: { + "restart-state-fixture": { workflow: { state: "waiting" } }, + }, + pluginNextTurnInjections: { + "restart-state-fixture": [ + { + id: "resume", + pluginId: "restart-state-fixture", + text: "resume", + placement: "prepend_context", + createdAt: 1, + }, + ], + }, + }; + return undefined; + }); + + await expect( + runPluginHostCleanup({ + cfg: tempConfig, + registry: registry.registry, + pluginId: "restart-state-fixture", + reason: "restart", + }), + ).resolves.toMatchObject({ failures: [] }); + + const stored = loadSessionStore(storePath, { skipCache: true }); + expect(stored["agent:main:main"]?.pluginExtensions).toEqual({ + "restart-state-fixture": { workflow: { state: "waiting" } }, + }); + expect(stored["agent:main:main"]?.pluginNextTurnInjections).toEqual({ + "restart-state-fixture": [ + { + id: "resume", + pluginId: "restart-state-fixture", + text: "resume", + placement: "prepend_context", + createdAt: 1, + }, + ], + }); + }, + }); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); + + it("cleans pending injections for plugins that registered no host-hook callbacks", async () => { + const previousRegistry = createEmptyPluginRegistry(); + previousRegistry.plugins.push( + createPluginRecord({ + id: "injection-only-fixture", + name: "Injection Only Fixture", + status: "loaded", + }), + ); + const stateDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-hooks-injection-only-"), + ); + const storePath = path.join(stateDir, "sessions.json"); + const tempConfig = { + session: { store: storePath }, + }; + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + try { + process.env.OPENCLAW_STATE_DIR = stateDir; + await withTempConfig({ + cfg: tempConfig, + run: async () => { + await updateSessionStore(storePath, (store) => { + store["agent:main:main"] = { + sessionId: "session-1", + updatedAt: Date.now(), + pluginNextTurnInjections: { + "injection-only-fixture": [ + { + id: "resume", + pluginId: "injection-only-fixture", + text: "resume", + placement: "prepend_context", + createdAt: 1, + }, + ], + }, + }; + return undefined; + }); + + await expect( + cleanupReplacedPluginHostRegistry({ + cfg: tempConfig, + previousRegistry, + nextRegistry: createEmptyPluginRegistry(), + }), + ).resolves.toMatchObject({ failures: [] }); + + const stored = loadSessionStore(storePath, { skipCache: true }); + expect(stored["agent:main:main"]?.pluginNextTurnInjections).toBeUndefined(); + }, + }); + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/plugins/hook-before-agent-start.types.ts b/src/plugins/hook-before-agent-start.types.ts index 2acd7539367..34e5b8039b1 100644 --- a/src/plugins/hook-before-agent-start.types.ts +++ b/src/plugins/hook-before-agent-start.types.ts @@ -28,6 +28,7 @@ export type PluginHookBeforePromptBuildEvent = { export type PluginHookBeforePromptBuildResult = { systemPrompt?: string; prependContext?: string; + appendContext?: string; /** * Prepended to the agent system prompt so providers can cache it (e.g. prompt caching). * Use for static plugin guidance instead of prependContext to avoid per-turn token cost. @@ -43,6 +44,7 @@ export type PluginHookBeforePromptBuildResult = { export const PLUGIN_PROMPT_MUTATION_RESULT_FIELDS = [ "systemPrompt", "prependContext", + "appendContext", "prependSystemContext", "appendSystemContext", ] as const satisfies readonly (keyof PluginHookBeforePromptBuildResult)[]; diff --git a/src/plugins/hook-types.ts b/src/plugins/hook-types.ts index 5fe204b9bd2..2613662259b 100644 --- a/src/plugins/hook-types.ts +++ b/src/plugins/hook-types.ts @@ -29,6 +29,12 @@ import type { PluginHookMessageSendingResult, PluginHookMessageSentEvent, } from "./hook-message.types.js"; +import type { + PluginAgentTurnPrepareEvent, + PluginAgentTurnPrepareResult, + PluginHeartbeatPromptContributionEvent, + PluginHeartbeatPromptContributionResult, +} from "./host-hook-turn-types.js"; export type { PluginHookBeforeAgentStartEvent, @@ -44,6 +50,12 @@ export { PLUGIN_PROMPT_MUTATION_RESULT_FIELDS, stripPromptMutationFieldsFromLegacyHookResult, } from "./hook-before-agent-start.types.js"; +export type { + PluginAgentTurnPrepareEvent, + PluginAgentTurnPrepareResult, + PluginHeartbeatPromptContributionEvent, + PluginHeartbeatPromptContributionResult, +} from "./host-hook-turn-types.js"; export type { PluginHookInboundClaimContext, PluginHookInboundClaimEvent, @@ -56,6 +68,7 @@ export type { export type PluginHookName = | "before_model_resolve" + | "agent_turn_prepare" | "before_prompt_build" | "before_agent_start" | "before_agent_reply" @@ -84,12 +97,14 @@ export type PluginHookName = | "subagent_ended" | "gateway_start" | "gateway_stop" + | "heartbeat_prompt_contribution" | "before_dispatch" | "reply_dispatch" | "before_install"; export const PLUGIN_HOOK_NAMES = [ "before_model_resolve", + "agent_turn_prepare", "before_prompt_build", "before_agent_start", "before_agent_reply", @@ -118,6 +133,7 @@ export const PLUGIN_HOOK_NAMES = [ "subagent_ended", "gateway_start", "gateway_stop", + "heartbeat_prompt_contribution", "before_dispatch", "reply_dispatch", "before_install", @@ -134,8 +150,10 @@ export const isPluginHookName = (hookName: unknown): hookName is PluginHookName typeof hookName === "string" && pluginHookNameSet.has(hookName as PluginHookName); export const PROMPT_INJECTION_HOOK_NAMES = [ + "agent_turn_prepare", "before_prompt_build", "before_agent_start", + "heartbeat_prompt_contribution", ] as const satisfies readonly PluginHookName[]; export type PromptInjectionHookName = (typeof PROMPT_INJECTION_HOOK_NAMES)[number]; @@ -713,6 +731,10 @@ export type PluginHookBeforeInstallResult = { }; export type PluginHookHandlerMap = { + agent_turn_prepare: ( + event: PluginAgentTurnPrepareEvent, + ctx: PluginHookAgentContext, + ) => Promise | PluginAgentTurnPrepareResult | void; before_model_resolve: ( event: PluginHookBeforeModelResolveEvent, ctx: PluginHookAgentContext, @@ -840,6 +862,13 @@ export type PluginHookHandlerMap = { event: PluginHookGatewayStopEvent, ctx: PluginHookGatewayContext, ) => Promise | void; + heartbeat_prompt_contribution: ( + event: PluginHeartbeatPromptContributionEvent, + ctx: PluginHookAgentContext, + ) => + | Promise + | PluginHeartbeatPromptContributionResult + | void; before_install: ( event: PluginHookBeforeInstallEvent, ctx: PluginHookBeforeInstallContext, diff --git a/src/plugins/hooks.ts b/src/plugins/hooks.ts index b4350eb2d8f..eeb9953bf2e 100644 --- a/src/plugins/hooks.ts +++ b/src/plugins/hooks.ts @@ -41,6 +41,10 @@ import type { PluginHookBeforeResetEvent, PluginHookBeforeToolCallEvent, PluginHookBeforeToolCallResult, + PluginAgentTurnPrepareEvent, + PluginAgentTurnPrepareResult, + PluginHeartbeatPromptContributionEvent, + PluginHeartbeatPromptContributionResult, PluginHookGatewayContext, PluginHookGatewayStartEvent, PluginHookGatewayStopEvent, @@ -247,6 +251,10 @@ export function createHookRunner( left: acc?.prependContext, right: next.prependContext, }), + appendContext: concatOptionalTextSegments({ + left: acc?.appendContext, + right: next.appendContext, + }), prependSystemContext: concatOptionalTextSegments({ left: acc?.prependSystemContext, right: next.prependSystemContext, @@ -257,6 +265,23 @@ export function createHookRunner( }), }); + const mergeAgentTurnPrepare = < + TResult extends { prependContext?: string; appendContext?: string }, + >( + acc: TResult | undefined, + next: TResult, + ): TResult => + ({ + prependContext: concatOptionalTextSegments({ + left: acc?.prependContext, + right: next.prependContext, + }), + appendContext: concatOptionalTextSegments({ + left: acc?.appendContext, + right: next.appendContext, + }), + }) as TResult; + const mergeBeforeAgentFinalize = ( acc: PluginHookBeforeAgentFinalizeResult | undefined, next: PluginHookBeforeAgentFinalizeResult, @@ -587,6 +612,18 @@ export function createHookRunner( ); } + async function runAgentTurnPrepare( + event: PluginAgentTurnPrepareEvent, + ctx: PluginHookAgentContext, + ): Promise { + return runModifyingHook<"agent_turn_prepare", PluginAgentTurnPrepareResult>( + "agent_turn_prepare", + event, + ctx, + { mergeResults: mergeAgentTurnPrepare }, + ); + } + /** * Run before_agent_start hook. * Legacy compatibility hook that combines model resolve + prompt build phases. @@ -1142,6 +1179,16 @@ export function createHookRunner( return runVoidHook("gateway_stop", event, ctx); } + async function runHeartbeatPromptContribution( + event: PluginHeartbeatPromptContributionEvent, + ctx: PluginHookAgentContext, + ): Promise { + return runModifyingHook< + "heartbeat_prompt_contribution", + PluginHeartbeatPromptContributionResult + >("heartbeat_prompt_contribution", event, ctx, { mergeResults: mergeAgentTurnPrepare }); + } + // ========================================================================= // Skill Install Hooks // ========================================================================= @@ -1198,6 +1245,7 @@ export function createHookRunner( return { // Agent hooks runBeforeModelResolve, + runAgentTurnPrepare, runBeforePromptBuild, runBeforeAgentStart, runBeforeAgentReply, @@ -1235,6 +1283,7 @@ export function createHookRunner( // Gateway hooks runGatewayStart, runGatewayStop, + runHeartbeatPromptContribution, // Install hooks runBeforeInstall, // Utility diff --git a/src/plugins/host-hook-cleanup.ts b/src/plugins/host-hook-cleanup.ts new file mode 100644 index 00000000000..1ff2aca7036 --- /dev/null +++ b/src/plugins/host-hook-cleanup.ts @@ -0,0 +1,257 @@ +import fs from "node:fs"; +import { updateSessionStore } from "../config/sessions/store.js"; +import { resolveAllAgentSessionStoreTargetsSync } from "../config/sessions/targets.js"; +import type { SessionEntry } from "../config/sessions/types.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { cleanupPluginSessionSchedulerJobs, clearPluginRunContext } from "./host-hook-runtime.js"; +import type { PluginHostCleanupReason } from "./host-hooks.js"; +import type { PluginRegistry } from "./registry-types.js"; + +export type PluginHostCleanupFailure = { + pluginId: string; + hookId: string; + error: unknown; +}; + +export type PluginHostCleanupResult = { + cleanupCount: number; + failures: PluginHostCleanupFailure[]; +}; + +function shouldCleanPlugin(pluginId: string, filterPluginId?: string): boolean { + return !filterPluginId || pluginId === filterPluginId; +} + +export function clearPluginOwnedSessionState(entry: SessionEntry, pluginId?: string): void { + if (!pluginId) { + delete entry.pluginExtensions; + delete entry.pluginNextTurnInjections; + return; + } + if (entry.pluginExtensions) { + delete entry.pluginExtensions[pluginId]; + if (Object.keys(entry.pluginExtensions).length === 0) { + delete entry.pluginExtensions; + } + } + if (entry.pluginNextTurnInjections) { + delete entry.pluginNextTurnInjections[pluginId]; + if (Object.keys(entry.pluginNextTurnInjections).length === 0) { + delete entry.pluginNextTurnInjections; + } + } +} + +function hasPluginOwnedSessionState(entry: SessionEntry, pluginId?: string): boolean { + if (!pluginId) { + return Boolean(entry.pluginExtensions || entry.pluginNextTurnInjections); + } + return Boolean(entry.pluginExtensions?.[pluginId] || entry.pluginNextTurnInjections?.[pluginId]); +} + +function matchesCleanupSession( + entryKey: string, + entry: SessionEntry, + sessionKey?: string, +): boolean { + const normalizedSessionKey = normalizeLowercaseStringOrEmpty(sessionKey); + if (!normalizedSessionKey) { + return true; + } + return ( + normalizeLowercaseStringOrEmpty(entryKey) === normalizedSessionKey || + normalizeLowercaseStringOrEmpty(entry.sessionId) === normalizedSessionKey + ); +} + +async function clearPluginOwnedSessionStores(params: { + cfg: OpenClawConfig; + pluginId?: string; + sessionKey?: string; +}): Promise { + if (!params.pluginId && !params.sessionKey) { + return 0; + } + const storePaths = new Set( + resolveAllAgentSessionStoreTargetsSync(params.cfg) + .map((target) => target.storePath) + .filter((storePath) => fs.existsSync(storePath)), + ); + let cleared = 0; + for (const storePath of storePaths) { + cleared += await updateSessionStore(storePath, (store) => { + let clearedInStore = 0; + const now = Date.now(); + for (const [entryKey, entry] of Object.entries(store)) { + if ( + !matchesCleanupSession(entryKey, entry, params.sessionKey) || + !hasPluginOwnedSessionState(entry, params.pluginId) + ) { + continue; + } + clearPluginOwnedSessionState(entry, params.pluginId); + entry.updatedAt = now; + clearedInStore += 1; + } + return clearedInStore; + }); + } + return cleared; +} + +export async function runPluginHostCleanup(params: { + cfg: OpenClawConfig; + registry?: PluginRegistry | null; + pluginId?: string; + reason: PluginHostCleanupReason; + sessionKey?: string; + runId?: string; + preserveSchedulerJobIds?: ReadonlySet; +}): Promise { + const persistentCleanupCount = + params.reason === "restart" + ? 0 + : await clearPluginOwnedSessionStores({ + cfg: params.cfg, + pluginId: params.pluginId, + sessionKey: params.sessionKey, + }); + const registry = params.registry; + if (!registry) { + return { cleanupCount: persistentCleanupCount, failures: [] }; + } + const failures: PluginHostCleanupFailure[] = []; + let cleanupCount = persistentCleanupCount; + for (const registration of registry.sessionExtensions ?? []) { + if (!shouldCleanPlugin(registration.pluginId, params.pluginId)) { + continue; + } + const cleanup = registration.extension.cleanup; + if (!cleanup) { + continue; + } + try { + await cleanup({ + reason: params.reason, + sessionKey: params.sessionKey, + }); + cleanupCount += 1; + } catch (error) { + failures.push({ + pluginId: registration.pluginId, + hookId: `session:${registration.extension.namespace}`, + error, + }); + } + } + for (const registration of registry.runtimeLifecycles ?? []) { + if (!shouldCleanPlugin(registration.pluginId, params.pluginId)) { + continue; + } + const cleanup = registration.lifecycle.cleanup; + if (!cleanup) { + continue; + } + try { + await cleanup({ + reason: params.reason, + sessionKey: params.sessionKey, + runId: params.runId, + }); + cleanupCount += 1; + } catch (error) { + failures.push({ + pluginId: registration.pluginId, + hookId: `runtime:${registration.lifecycle.id}`, + error, + }); + } + } + const schedulerFailures = await cleanupPluginSessionSchedulerJobs({ + pluginId: params.pluginId, + reason: params.reason, + sessionKey: params.sessionKey, + records: registry?.sessionSchedulerJobs, + preserveJobIds: params.preserveSchedulerJobIds, + }); + for (const failure of schedulerFailures) { + failures.push(failure); + } + if (params.pluginId || params.runId) { + clearPluginRunContext({ pluginId: params.pluginId, runId: params.runId }); + } + return { cleanupCount, failures }; +} + +function collectHostHookPluginIds(registry: PluginRegistry): Set { + const ids = new Set(); + for (const registration of registry.sessionExtensions ?? []) { + ids.add(registration.pluginId); + } + for (const registration of registry.runtimeLifecycles ?? []) { + ids.add(registration.pluginId); + } + for (const registration of registry.agentEventSubscriptions ?? []) { + ids.add(registration.pluginId); + } + for (const registration of registry.sessionSchedulerJobs ?? []) { + ids.add(registration.pluginId); + } + return ids; +} + +function collectLoadedPluginIds(registry: PluginRegistry): Set { + return new Set( + registry.plugins.filter((plugin) => plugin.status === "loaded").map((plugin) => plugin.id), + ); +} + +function collectSchedulerJobIds( + registry: PluginRegistry | null | undefined, + pluginId: string, +): Set { + return new Set( + (registry?.sessionSchedulerJobs ?? []) + .filter((registration) => registration.pluginId === pluginId) + .map((registration) => + typeof registration.job.id === "string" ? registration.job.id.trim() : "", + ) + .filter(Boolean), + ); +} + +export async function cleanupReplacedPluginHostRegistry(params: { + cfg: OpenClawConfig; + previousRegistry?: PluginRegistry | null; + nextRegistry?: PluginRegistry | null; +}): Promise { + const previousRegistry = params.previousRegistry; + if (!previousRegistry || previousRegistry === params.nextRegistry) { + return { cleanupCount: 0, failures: [] }; + } + const nextPluginIds = params.nextRegistry + ? collectLoadedPluginIds(params.nextRegistry) + : new Set(); + const previousPluginIds = new Set([ + ...collectLoadedPluginIds(previousRegistry), + ...collectHostHookPluginIds(previousRegistry), + ]); + const failures: PluginHostCleanupFailure[] = []; + let cleanupCount = 0; + for (const pluginId of previousPluginIds) { + const restarted = nextPluginIds.has(pluginId); + const result = await runPluginHostCleanup({ + cfg: params.cfg, + registry: previousRegistry, + pluginId, + reason: restarted ? "restart" : "disable", + preserveSchedulerJobIds: restarted + ? collectSchedulerJobIds(params.nextRegistry, pluginId) + : undefined, + }); + cleanupCount += result.cleanupCount; + failures.push(...result.failures); + } + return { cleanupCount, failures }; +} diff --git a/src/plugins/host-hook-json.ts b/src/plugins/host-hook-json.ts new file mode 100644 index 00000000000..d25b44fc35b --- /dev/null +++ b/src/plugins/host-hook-json.ts @@ -0,0 +1,79 @@ +export type PluginJsonPrimitive = string | number | boolean | null; +export type PluginJsonValue = + | PluginJsonPrimitive + | PluginJsonValue[] + | { [key: string]: PluginJsonValue }; + +export type PluginJsonValueLimits = { + maxDepth: number; + maxNodes: number; + maxObjectKeys: number; + maxStringLength: number; + maxSerializedBytes: number; +}; + +export const PLUGIN_JSON_VALUE_LIMITS: PluginJsonValueLimits = { + maxDepth: 32, + maxNodes: 4096, + maxObjectKeys: 512, + maxStringLength: 64 * 1024, + maxSerializedBytes: 256 * 1024, +}; + +function isPluginJsonValueWithinLimits( + value: unknown, + limits: PluginJsonValueLimits, + state: { depth: number; nodes: number }, +): value is PluginJsonValue { + state.nodes += 1; + if (state.nodes > limits.maxNodes || state.depth > limits.maxDepth) { + return false; + } + if (value === null || typeof value === "boolean") { + return true; + } + if (typeof value === "string") { + return value.length <= limits.maxStringLength; + } + if (typeof value === "number") { + return Number.isFinite(value); + } + if (Array.isArray(value)) { + state.depth += 1; + const ok = value.every((entry) => isPluginJsonValueWithinLimits(entry, limits, state)); + state.depth -= 1; + return ok; + } + if (typeof value !== "object") { + return false; + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + return false; + } + const entries = Object.entries(value as Record); + if (entries.length > limits.maxObjectKeys) { + return false; + } + state.depth += 1; + const ok = entries.every( + ([key, entry]) => + key.length <= limits.maxStringLength && isPluginJsonValueWithinLimits(entry, limits, state), + ); + state.depth -= 1; + return ok; +} + +export function isPluginJsonValue(value: unknown): value is PluginJsonValue { + if (!isPluginJsonValueWithinLimits(value, PLUGIN_JSON_VALUE_LIMITS, { depth: 0, nodes: 0 })) { + return false; + } + try { + return ( + Buffer.byteLength(JSON.stringify(value), "utf8") <= + PLUGIN_JSON_VALUE_LIMITS.maxSerializedBytes + ); + } catch { + return false; + } +} diff --git a/src/plugins/host-hook-runtime.ts b/src/plugins/host-hook-runtime.ts new file mode 100644 index 00000000000..3fca1e4a565 --- /dev/null +++ b/src/plugins/host-hook-runtime.ts @@ -0,0 +1,507 @@ +import type { AgentEventPayload } from "../infra/agent-events.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { resolveGlobalSingleton } from "../shared/global-singleton.js"; +import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { + isPluginJsonValue, + type PluginHostCleanupReason, + type PluginJsonValue, + type PluginRunContextGetParams, + type PluginRunContextPatch, + type PluginSessionSchedulerJobHandle, + type PluginSessionSchedulerJobRegistration, +} from "./host-hooks.js"; +import type { PluginRegistry } from "./registry-types.js"; + +type PluginRunContextNamespaces = Map; +type PluginRunContextByPlugin = Map; + +type SchedulerJobRecord = { + pluginId: string; + pluginName?: string; + job: PluginSessionSchedulerJobRegistration; + generation: number; +}; + +type PluginHostRuntimeState = { + runContextByRunId: Map; + schedulerJobsByPlugin: Map>; + nextSchedulerJobGeneration: number; + pendingAgentEventHandlersByRunId: Map>>; + closedRunIds: Set; +}; + +const PLUGIN_HOST_RUNTIME_STATE_KEY = Symbol.for("openclaw.pluginHostRuntimeState"); +const CLOSED_RUN_IDS_MAX = 512; +const log = createSubsystemLogger("plugins/host-hooks"); + +function getPluginHostRuntimeState(): PluginHostRuntimeState { + return resolveGlobalSingleton(PLUGIN_HOST_RUNTIME_STATE_KEY, () => ({ + runContextByRunId: new Map(), + schedulerJobsByPlugin: new Map(), + nextSchedulerJobGeneration: 1, + pendingAgentEventHandlersByRunId: new Map(), + closedRunIds: new Set(), + })); +} + +function normalizeNamespace(value: string | undefined): string { + return (value ?? "").trim(); +} + +function copyJsonValue(value: PluginJsonValue): PluginJsonValue { + return structuredClone(value); +} + +function markPluginRunClosed(runId: string): void { + const state = getPluginHostRuntimeState(); + state.closedRunIds.delete(runId); + state.closedRunIds.add(runId); + while (state.closedRunIds.size > CLOSED_RUN_IDS_MAX) { + const oldest = state.closedRunIds.values().next().value; + if (oldest === undefined) { + break; + } + state.closedRunIds.delete(oldest); + } +} + +function isPluginRunClosed(runId: string): boolean { + return getPluginHostRuntimeState().closedRunIds.has(runId); +} + +function trackAgentEventHandler(runId: string, pending: Promise): void { + const state = getPluginHostRuntimeState(); + const handlers = state.pendingAgentEventHandlersByRunId.get(runId) ?? new Set(); + handlers.add(pending); + state.pendingAgentEventHandlersByRunId.set(runId, handlers); + void pending.finally(() => { + handlers.delete(pending); + if (handlers.size === 0) { + state.pendingAgentEventHandlersByRunId.delete(runId); + } + }); +} + +function getPluginRunContextNamespaces(params: { + runId: string; + pluginId: string; + create?: boolean; +}): PluginRunContextNamespaces | undefined { + const state = getPluginHostRuntimeState(); + let byPlugin = state.runContextByRunId.get(params.runId); + if (!byPlugin && params.create) { + byPlugin = new Map(); + state.runContextByRunId.set(params.runId, byPlugin); + } + if (!byPlugin) { + return undefined; + } + let namespaces = byPlugin.get(params.pluginId); + if (!namespaces && params.create) { + namespaces = new Map(); + byPlugin.set(params.pluginId, namespaces); + } + return namespaces; +} + +export function setPluginRunContext(params: { + pluginId: string; + patch: PluginRunContextPatch; +}): boolean { + const runId = normalizeOptionalString(params.patch.runId); + const namespace = normalizeNamespace(params.patch.namespace); + if (!runId || !namespace) { + return false; + } + if (isPluginRunClosed(runId)) { + return false; + } + // Only an explicit `unset: true` deletes the run-context entry — silently + // treating an accidentally-omitted `value` as a clear is surprising and + // diverges from the stricter `sessions.pluginPatch` semantics. + if (params.patch.unset === true) { + clearPluginRunContext({ + pluginId: params.pluginId, + runId, + namespace, + }); + return true; + } + if (params.patch.value === undefined) { + return false; + } + if (!isPluginJsonValue(params.patch.value)) { + return false; + } + const namespaces = getPluginRunContextNamespaces({ + runId, + pluginId: params.pluginId, + create: true, + }); + namespaces?.set(namespace, copyJsonValue(params.patch.value)); + return true; +} + +// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Run-context JSON reads are caller-typed by namespace. +export function getPluginRunContext(params: { + pluginId: string; + get: PluginRunContextGetParams; +}): T | undefined { + const runId = normalizeOptionalString(params.get.runId); + const namespace = normalizeNamespace(params.get.namespace); + if (!runId || !namespace) { + return undefined; + } + const value = getPluginRunContextNamespaces({ + runId, + pluginId: params.pluginId, + })?.get(namespace); + return value === undefined ? undefined : (copyJsonValue(value) as T); +} + +export function clearPluginRunContext(params: { + pluginId?: string; + runId?: string; + namespace?: string; +}): void { + // Normalize namespace through the same trim() used by set/get so callers that + // pass whitespace or differently-formatted strings hit the same Map keys and + // don't leave orphan entries behind. + const normalizedNamespace = + params.namespace !== undefined ? normalizeNamespace(params.namespace) : undefined; + // An empty-after-trim namespace is treated as "no namespace filter" rather + // than as a literal-empty-string deletion: that matches the set/get rule that + // empty namespaces are not addressable, and it avoids silently no-op-ing the + // delete (which would otherwise look like a successful clear). + const namespaceFilter = + normalizedNamespace !== undefined && normalizedNamespace !== "" + ? normalizedNamespace + : undefined; + const state = getPluginHostRuntimeState(); + const runIds = params.runId ? [params.runId] : [...state.runContextByRunId.keys()]; + for (const runId of runIds) { + const byPlugin = state.runContextByRunId.get(runId); + if (!byPlugin) { + continue; + } + const pluginIds = params.pluginId ? [params.pluginId] : [...byPlugin.keys()]; + for (const pluginId of pluginIds) { + const namespaces = byPlugin.get(pluginId); + if (!namespaces) { + continue; + } + if (namespaceFilter !== undefined) { + namespaces.delete(namespaceFilter); + } else { + namespaces.clear(); + } + if (namespaces.size === 0) { + byPlugin.delete(pluginId); + } + } + if (byPlugin.size === 0) { + state.runContextByRunId.delete(runId); + } + } + if (params.runId && !params.pluginId && namespaceFilter === undefined) { + state.pendingAgentEventHandlersByRunId.delete(params.runId); + } +} + +function isTerminalAgentRunEvent(event: AgentEventPayload): boolean { + const phase = event.data?.phase; + return event.stream === "lifecycle" && (phase === "end" || phase === "error"); +} + +function logAgentEventSubscriptionFailure(params: { + pluginId: string; + subscriptionId: string; + error: unknown; +}): void { + log.warn( + `plugin agent event subscription failed: plugin=${params.pluginId} subscription=${params.subscriptionId} error=${String(params.error)}`, + ); +} + +export function dispatchPluginAgentEventSubscriptions(params: { + registry: PluginRegistry | null | undefined; + event: AgentEventPayload; +}): void { + const subscriptions = params.registry?.agentEventSubscriptions ?? []; + const pendingHandlers: Promise[] = []; + for (const registration of subscriptions) { + const streams = registration.subscription.streams; + if (streams && streams.length > 0 && !streams.includes(params.event.stream)) { + continue; + } + const pluginId = registration.pluginId; + const runId = params.event.runId; + const ctx = { + // oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Run-context JSON reads are caller-typed by namespace. + getRunContext: (namespace: string) => + getPluginRunContext({ pluginId, get: { runId, namespace } }), + setRunContext: (namespace: string, value: PluginJsonValue) => { + setPluginRunContext({ pluginId, patch: { runId, namespace, value } }); + }, + clearRunContext: (namespace?: string) => { + clearPluginRunContext({ pluginId, runId, namespace }); + }, + }; + try { + const pending = Promise.resolve( + registration.subscription.handle(structuredClone(params.event), ctx), + ).catch((error) => { + logAgentEventSubscriptionFailure({ + pluginId, + subscriptionId: registration.subscription.id, + error, + }); + }); + trackAgentEventHandler(runId, pending); + pendingHandlers.push(pending); + } catch (error) { + logAgentEventSubscriptionFailure({ + pluginId, + subscriptionId: registration.subscription.id, + error, + }); + } + } + if (isTerminalAgentRunEvent(params.event)) { + markPluginRunClosed(params.event.runId); + const pendingForRun = + getPluginHostRuntimeState().pendingAgentEventHandlersByRunId.get(params.event.runId) ?? + new Set(pendingHandlers); + void Promise.allSettled(pendingForRun).then(() => { + clearPluginRunContext({ runId: params.event.runId }); + }); + } +} + +export function registerPluginSessionSchedulerJob(params: { + pluginId: string; + pluginName?: string; + job: PluginSessionSchedulerJobRegistration; +}): PluginSessionSchedulerJobHandle | undefined { + const id = normalizeOptionalString(params.job.id); + const sessionKey = normalizeOptionalString(params.job.sessionKey); + const kind = normalizeOptionalString(params.job.kind); + if (!id || !sessionKey || !kind) { + return undefined; + } + const state = getPluginHostRuntimeState(); + const jobs = state.schedulerJobsByPlugin.get(params.pluginId) ?? new Map(); + const generation = state.nextSchedulerJobGeneration++; + jobs.set(id, { + pluginId: params.pluginId, + pluginName: params.pluginName, + job: { ...params.job, id, sessionKey, kind }, + generation, + }); + state.schedulerJobsByPlugin.set(params.pluginId, jobs); + return { id, pluginId: params.pluginId, sessionKey, kind }; +} + +function deletePluginSessionSchedulerJob(params: { + pluginId: string; + jobId: string; + sessionKey?: string; + expectedGeneration?: number; +}): void { + const state = getPluginHostRuntimeState(); + const jobs = state.schedulerJobsByPlugin.get(params.pluginId); + const record = jobs?.get(params.jobId); + if (!jobs || !record) { + return; + } + if (params.sessionKey && record.job.sessionKey !== params.sessionKey) { + return; + } + if (params.expectedGeneration !== undefined && record.generation !== params.expectedGeneration) { + return; + } + jobs.delete(params.jobId); + if (jobs.size === 0) { + state.schedulerJobsByPlugin.delete(params.pluginId); + } +} + +function hasPluginSessionSchedulerJob(params: { + pluginId: string; + jobId: string; + sessionKey?: string; + generation?: number; +}): boolean { + const state = getPluginHostRuntimeState(); + const record = state.schedulerJobsByPlugin.get(params.pluginId)?.get(params.jobId); + if (!record) { + return false; + } + if (params.sessionKey && record.job.sessionKey !== params.sessionKey) { + return false; + } + return params.generation === undefined || record.generation === params.generation; +} + +export function getPluginSessionSchedulerJobGeneration(params: { + pluginId: string; + jobId: string; + sessionKey?: string; +}): number | undefined { + const state = getPluginHostRuntimeState(); + const record = state.schedulerJobsByPlugin.get(params.pluginId)?.get(params.jobId); + if (!record) { + return undefined; + } + if (params.sessionKey && record.job.sessionKey !== params.sessionKey) { + return undefined; + } + return record.generation; +} + +export async function cleanupPluginSessionSchedulerJobs(params: { + pluginId?: string; + reason: PluginHostCleanupReason; + sessionKey?: string; + records?: readonly { + pluginId: string; + pluginName?: string; + job: PluginSessionSchedulerJobRegistration; + generation?: number; + }[]; + preserveJobIds?: ReadonlySet; +}): Promise> { + const state = getPluginHostRuntimeState(); + const failures: Array<{ pluginId: string; hookId: string; error: unknown }> = []; + if (params.records) { + for (const record of params.records) { + if (params.pluginId && record.pluginId !== params.pluginId) { + continue; + } + const jobId = normalizeOptionalString(record.job.id); + const sessionKey = normalizeOptionalString(record.job.sessionKey); + if (!jobId || !sessionKey) { + continue; + } + if (params.sessionKey && sessionKey !== params.sessionKey) { + continue; + } + const liveGeneration = getPluginSessionSchedulerJobGeneration({ + pluginId: record.pluginId, + jobId, + sessionKey, + }); + if (record.generation !== undefined && liveGeneration === undefined) { + continue; + } + if ( + record.generation === undefined && + !hasPluginSessionSchedulerJob({ + pluginId: record.pluginId, + jobId, + sessionKey, + }) + ) { + continue; + } + const preserveJob = params.preserveJobIds?.has(jobId) ?? false; + if ( + preserveJob && + (record.generation === undefined || liveGeneration === record.generation) + ) { + continue; + } + // A newer generation may already own this id. The old cleanup callback can + // still release plugin-owned resources, while deletion below is generation + // matched so it cannot remove the newer live record. + try { + await record.job.cleanup?.({ + reason: params.reason, + sessionKey, + jobId, + }); + } catch (error) { + failures.push({ + pluginId: record.pluginId, + hookId: `scheduler:${jobId}`, + error, + }); + continue; + } + deletePluginSessionSchedulerJob({ + pluginId: record.pluginId, + jobId, + sessionKey, + expectedGeneration: record.generation, + }); + } + return failures; + } + const pluginIds = params.pluginId ? [params.pluginId] : [...state.schedulerJobsByPlugin.keys()]; + for (const pluginId of pluginIds) { + const jobs = state.schedulerJobsByPlugin.get(pluginId); + if (!jobs) { + continue; + } + for (const [jobId, record] of jobs.entries()) { + if (params.sessionKey && record.job.sessionKey !== params.sessionKey) { + continue; + } + try { + await record.job.cleanup?.({ + reason: params.reason, + sessionKey: record.job.sessionKey, + jobId, + }); + } catch (error) { + failures.push({ + pluginId, + hookId: `scheduler:${jobId}`, + error, + }); + continue; + } + jobs.delete(jobId); + } + if (jobs.size === 0) { + state.schedulerJobsByPlugin.delete(pluginId); + } + } + return failures; +} + +export function clearPluginHostRuntimeState(params?: { pluginId?: string; runId?: string }): void { + clearPluginRunContext(params ?? {}); + if (params?.pluginId) { + getPluginHostRuntimeState().schedulerJobsByPlugin.delete(params.pluginId); + } else if (!params?.runId) { + const state = getPluginHostRuntimeState(); + state.schedulerJobsByPlugin.clear(); + state.pendingAgentEventHandlersByRunId.clear(); + state.closedRunIds.clear(); + } +} + +export function listPluginSessionSchedulerJobs( + pluginId?: string, +): PluginSessionSchedulerJobHandle[] { + const state = getPluginHostRuntimeState(); + const records: PluginSessionSchedulerJobHandle[] = []; + const pluginIds = pluginId ? [pluginId] : [...state.schedulerJobsByPlugin.keys()]; + for (const currentPluginId of pluginIds) { + const jobs = state.schedulerJobsByPlugin.get(currentPluginId); + if (!jobs) { + continue; + } + for (const record of jobs.values()) { + records.push({ + id: record.job.id, + pluginId: currentPluginId, + sessionKey: record.job.sessionKey, + kind: record.job.kind, + }); + } + } + return records.toSorted((left, right) => left.id.localeCompare(right.id)); +} diff --git a/src/plugins/host-hook-state.ts b/src/plugins/host-hook-state.ts new file mode 100644 index 00000000000..529f0fd68e4 --- /dev/null +++ b/src/plugins/host-hook-state.ts @@ -0,0 +1,590 @@ +import { randomUUID } from "node:crypto"; +import { loadSessionStore, updateSessionStore, type SessionEntry } from "../config/sessions.js"; +import { resolveAgentMainSessionKey } from "../config/sessions/main-session.js"; +import { resolveStorePath } from "../config/sessions/paths.js"; +import { + resolveAllAgentSessionStoreTargetsSync, + type SessionStoreTarget, +} from "../config/sessions/targets.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + resolveSessionStoreAgentId, + resolveSessionStoreKey, +} from "../gateway/session-store-key.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../shared/string-coerce.js"; +export { clearPluginOwnedSessionState } from "./host-hook-cleanup.js"; +import { + buildPluginAgentTurnPrepareContext, + isPluginJsonValue, + type PluginAgentTurnPrepareResult, + type PluginJsonValue, + type PluginNextTurnInjection, + type PluginNextTurnInjectionEnqueueResult, + type PluginNextTurnInjectionRecord, + type PluginSessionExtensionProjection, +} from "./host-hooks.js"; +import { getActivePluginRegistry } from "./runtime.js"; + +const log = createSubsystemLogger("plugins/host-hook-state"); +const PROJECTION_FAILED = Symbol("plugin-session-extension-projection-failed"); +const MAX_PLUGIN_NEXT_TURN_INJECTION_TEXT_LENGTH = 32 * 1024; +const MAX_PLUGIN_NEXT_TURN_INJECTION_IDEMPOTENCY_KEY_LENGTH = 512; +const MAX_PLUGIN_NEXT_TURN_INJECTIONS_PER_SESSION = 32; + +function isStorePathTemplate(store?: string): boolean { + return typeof store === "string" && store.includes("{agentId}"); +} + +function normalizeNamespace(value: string): string { + return value.trim(); +} + +function copyJsonValue(value: PluginJsonValue): PluginJsonValue { + return structuredClone(value); +} + +function isPluginNextTurnInjectionPlacement( + value: unknown, +): value is PluginNextTurnInjectionRecord["placement"] { + return value === "prepend_context" || value === "append_context"; +} + +function isPluginNextTurnInjectionRecord(value: unknown): value is PluginNextTurnInjectionRecord { + if (!value || typeof value !== "object") { + return false; + } + const candidate = value as Partial; + return ( + typeof candidate.id === "string" && + typeof candidate.pluginId === "string" && + typeof candidate.text === "string" && + typeof candidate.createdAt === "number" && + Number.isFinite(candidate.createdAt) && + isPluginNextTurnInjectionPlacement(candidate.placement) && + (candidate.ttlMs === undefined || + (typeof candidate.ttlMs === "number" && + Number.isFinite(candidate.ttlMs) && + candidate.ttlMs >= 0)) && + (candidate.idempotencyKey === undefined || typeof candidate.idempotencyKey === "string") + ); +} + +function isExpired(entry: unknown, now: number) { + if (!isPluginNextTurnInjectionRecord(entry)) { + return true; + } + return typeof entry.ttlMs === "number" && entry.ttlMs >= 0 && now - entry.createdAt > entry.ttlMs; +} + +function findStoreKeysIgnoreCase(store: Record, targetKey: string): string[] { + const lowered = normalizeLowercaseStringOrEmpty(targetKey); + const matches: string[] = []; + for (const key of Object.keys(store)) { + if (normalizeLowercaseStringOrEmpty(key) === lowered) { + matches.push(key); + } + } + return matches; +} + +function findFreshestStoreMatch( + store: Record, + ...candidates: string[] +): { entry: SessionEntry; key: string } | undefined { + let freshest: { entry: SessionEntry; key: string } | undefined; + for (const candidate of candidates) { + const trimmed = normalizeOptionalString(candidate) ?? ""; + if (!trimmed) { + continue; + } + const exact = store[trimmed]; + if (exact && (!freshest || (exact.updatedAt ?? 0) >= (freshest.entry.updatedAt ?? 0))) { + freshest = { entry: exact, key: trimmed }; + } + for (const legacyKey of findStoreKeysIgnoreCase(store, trimmed)) { + const entry = store[legacyKey]; + if (entry && (!freshest || (entry.updatedAt ?? 0) >= (freshest.entry.updatedAt ?? 0))) { + freshest = { entry, key: legacyKey }; + } + } + } + return freshest; +} + +function resolveSessionStoreCandidates(params: { + cfg: OpenClawConfig; + agentId: string; +}): SessionStoreTarget[] { + const storeConfig = params.cfg.session?.store; + const defaultTarget = { + agentId: params.agentId, + storePath: resolveStorePath(storeConfig, { agentId: params.agentId }), + }; + if (!isStorePathTemplate(storeConfig)) { + return [defaultTarget]; + } + const targets = new Map(); + targets.set(defaultTarget.storePath, defaultTarget); + for (const target of resolveAllAgentSessionStoreTargetsSync(params.cfg)) { + if (target.agentId === params.agentId) { + targets.set(target.storePath, target); + } + } + return [...targets.values()]; +} + +function buildSessionStoreScanTargets(params: { + cfg: OpenClawConfig; + key: string; + canonicalKey: string; + agentId: string; +}): string[] { + const targets = new Set(); + if (params.canonicalKey) { + targets.add(params.canonicalKey); + } + if (params.key && params.key !== params.canonicalKey) { + targets.add(params.key); + } + if (params.canonicalKey === "global" || params.canonicalKey === "unknown") { + return [...targets]; + } + const agentMainKey = resolveAgentMainSessionKey({ + cfg: params.cfg, + agentId: params.agentId, + }); + if (params.canonicalKey === agentMainKey) { + targets.add(`agent:${params.agentId}:main`); + } + return [...targets]; +} + +function loadPluginHostHookSessionEntry(params: { cfg: OpenClawConfig; sessionKey: string }): { + storePath: string; + entry?: SessionEntry; + canonicalKey: string; + storeKey: string; +} { + const key = normalizeOptionalString(params.sessionKey) ?? ""; + const cfg = params.cfg; + const canonicalKey = resolveSessionStoreKey({ cfg, sessionKey: key }); + const agentId = resolveSessionStoreAgentId(cfg, canonicalKey); + const scanTargets = buildSessionStoreScanTargets({ cfg, key, canonicalKey, agentId }); + const candidates = resolveSessionStoreCandidates({ cfg, agentId }); + const fallback = candidates[0] ?? { + agentId, + storePath: resolveStorePath(cfg.session?.store, { agentId }), + }; + let selectedStorePath = fallback.storePath; + let selectedMatch = findFreshestStoreMatch(loadSessionStore(fallback.storePath), ...scanTargets); + for (let index = 1; index < candidates.length; index += 1) { + const candidate = candidates[index]; + if (!candidate) { + continue; + } + const match = findFreshestStoreMatch(loadSessionStore(candidate.storePath), ...scanTargets); + if ( + match && + (!selectedMatch || (match.entry.updatedAt ?? 0) >= (selectedMatch.entry.updatedAt ?? 0)) + ) { + selectedStorePath = candidate.storePath; + selectedMatch = match; + } + } + return { + storePath: selectedStorePath, + entry: selectedMatch?.entry, + canonicalKey, + storeKey: selectedMatch?.key ?? canonicalKey, + }; +} + +function isPluginPromptInjectionEnabled(cfg: OpenClawConfig, pluginId: string): boolean { + const entry = cfg.plugins?.entries?.[pluginId]; + return entry?.hooks?.allowPromptInjection !== false; +} + +function toPluginNextTurnInjectionRecord(params: { + pluginId: string; + pluginName?: string; + injection: PluginNextTurnInjection; + now: number; +}): PluginNextTurnInjectionRecord { + return { + id: params.injection.idempotencyKey?.trim() || randomUUID(), + pluginId: params.pluginId, + pluginName: params.pluginName, + text: params.injection.text, + idempotencyKey: params.injection.idempotencyKey?.trim() || undefined, + placement: params.injection.placement ?? "prepend_context", + ttlMs: params.injection.ttlMs, + createdAt: params.now, + metadata: params.injection.metadata, + }; +} + +export async function enqueuePluginNextTurnInjection(params: { + cfg: OpenClawConfig; + pluginId: string; + pluginName?: string; + injection: PluginNextTurnInjection; + now?: number; +}): Promise { + if (typeof params.injection.sessionKey !== "string") { + return { enqueued: false, id: "", sessionKey: "" }; + } + const sessionKey = params.injection.sessionKey.trim(); + if (!sessionKey) { + return { enqueued: false, id: "", sessionKey }; + } + if (typeof params.injection.text !== "string") { + return { enqueued: false, id: "", sessionKey }; + } + const text = params.injection.text.trim(); + if (!text) { + return { enqueued: false, id: "", sessionKey }; + } + if (text.length > MAX_PLUGIN_NEXT_TURN_INJECTION_TEXT_LENGTH) { + return { enqueued: false, id: "", sessionKey }; + } + if (params.injection.metadata !== undefined && !isPluginJsonValue(params.injection.metadata)) { + return { enqueued: false, id: "", sessionKey }; + } + if ( + params.injection.idempotencyKey !== undefined && + (typeof params.injection.idempotencyKey !== "string" || + params.injection.idempotencyKey.trim().length === 0 || + params.injection.idempotencyKey.length > + MAX_PLUGIN_NEXT_TURN_INJECTION_IDEMPOTENCY_KEY_LENGTH) + ) { + return { enqueued: false, id: "", sessionKey }; + } + if ( + params.injection.placement !== undefined && + !isPluginNextTurnInjectionPlacement(params.injection.placement) + ) { + return { enqueued: false, id: "", sessionKey }; + } + if ( + params.injection.ttlMs !== undefined && + (!Number.isFinite(params.injection.ttlMs) || params.injection.ttlMs < 0) + ) { + return { enqueued: false, id: "", sessionKey }; + } + const loaded = loadPluginHostHookSessionEntry({ cfg: params.cfg, sessionKey }); + if (!loaded.entry) { + return { enqueued: false, id: "", sessionKey }; + } + const canonicalKey = loaded.canonicalKey ?? sessionKey; + const now = params.now ?? Date.now(); + const record = toPluginNextTurnInjectionRecord({ + pluginId: params.pluginId, + pluginName: params.pluginName, + injection: { ...params.injection, sessionKey, text }, + now, + }); + let enqueued = false; + let resultId = record.id; + await updateSessionStore(loaded.storePath, (store) => { + const entry = store[loaded.storeKey]; + if (!entry) { + return; + } + const injections = { ...entry.pluginNextTurnInjections }; + // Guard against malformed/hand-edited persisted state — a non-array value + // here would crash the spread/filter and break the whole session's enqueue. + const rawExisting = injections[params.pluginId]; + const existing = (Array.isArray(rawExisting) ? [...rawExisting] : []).filter( + (candidate): candidate is PluginNextTurnInjectionRecord => !isExpired(candidate, now), + ); + const duplicate = record.idempotencyKey + ? existing.find((candidate) => candidate.idempotencyKey === record.idempotencyKey) + : undefined; + if (duplicate) { + resultId = duplicate.id; + injections[params.pluginId] = existing; + entry.pluginNextTurnInjections = injections; + return; + } + if (existing.length >= MAX_PLUGIN_NEXT_TURN_INJECTIONS_PER_SESSION) { + injections[params.pluginId] = existing; + entry.pluginNextTurnInjections = injections; + return; + } + injections[params.pluginId] = [...existing, record]; + entry.pluginNextTurnInjections = injections; + entry.updatedAt = now; + enqueued = true; + }); + return { enqueued, id: resultId, sessionKey: canonicalKey }; +} + +export async function drainPluginNextTurnInjections(params: { + cfg: OpenClawConfig; + sessionKey?: string; + now?: number; +}): Promise { + const sessionKey = params.sessionKey?.trim(); + if (!sessionKey) { + return []; + } + const loaded = loadPluginHostHookSessionEntry({ cfg: params.cfg, sessionKey }); + if (!loaded.entry) { + return []; + } + // Avoid the locked re-save in updateSessionStore when there is nothing queued. + // Drain runs once per prompt build; the common case is no injections, so a + // pre-flight read keeps prompt-build off the session-store write path. + // (Concurrently-enqueued injections during this gap land on the next turn.) + if ( + !loaded.entry.pluginNextTurnInjections || + Object.keys(loaded.entry.pluginNextTurnInjections).length === 0 + ) { + return []; + } + const now = params.now ?? Date.now(); + return await updateSessionStore(loaded.storePath, (store) => { + const entry = store[loaded.storeKey]; + if (!entry?.pluginNextTurnInjections) { + return []; + } + const activePluginIds = new Set( + (getActivePluginRegistry()?.plugins ?? []) + .filter((plugin) => plugin.status === "loaded") + .map((plugin) => plugin.id), + ); + const drained: PluginNextTurnInjectionRecord[] = []; + for (const [pluginId, entries] of Object.entries(entry.pluginNextTurnInjections)) { + if (!activePluginIds.has(pluginId) || !isPluginPromptInjectionEnabled(params.cfg, pluginId)) { + continue; + } + // Guard against malformed/hand-edited persisted state — a non-array value + // here would crash .filter and break prompt-building for the session. + if (!Array.isArray(entries)) { + continue; + } + const liveEntries = entries.filter( + (candidate): candidate is PluginNextTurnInjectionRecord => !isExpired(candidate, now), + ); + drained.push(...liveEntries); + } + drained.sort((left, right) => left.createdAt - right.createdAt); + // A drain is the consume boundary for this session queue. Inactive plugin + // records are stale owner state and are discarded with expired records. + delete entry.pluginNextTurnInjections; + if (drained.length > 0) { + entry.updatedAt = now; + } + return drained; + }); +} + +export async function drainPluginNextTurnInjectionContext(params: { + cfg: OpenClawConfig; + sessionKey?: string; + now?: number; +}): Promise { + const queuedInjections = await drainPluginNextTurnInjections(params); + return { + queuedInjections, + ...buildPluginAgentTurnPrepareContext({ queuedInjections }), + }; +} + +export async function patchPluginSessionExtension(params: { + cfg: OpenClawConfig; + sessionKey: string; + pluginId: string; + namespace: string; + value?: PluginJsonValue; + unset?: boolean; +}): Promise<{ ok: true; key: string; value?: PluginJsonValue } | { ok: false; error: string }> { + const namespace = normalizeNamespace(params.namespace); + const pluginId = params.pluginId.trim(); + if (!pluginId || !namespace) { + return { ok: false, error: "pluginId and namespace are required" }; + } + if (params.unset === true && params.value !== undefined) { + return { ok: false, error: "plugin session extension cannot specify both unset and value" }; + } + if (params.value !== undefined && !isPluginJsonValue(params.value)) { + return { ok: false, error: "plugin session extension value must be JSON-compatible" }; + } + if (params.unset !== true && params.value === undefined) { + return { ok: false, error: "plugin session extension value is required unless unset is true" }; + } + const nextPluginValue = params.value as PluginJsonValue; + const registry = getActivePluginRegistry(); + const registered = (registry?.sessionExtensions ?? []).some( + (entry) => entry.pluginId === pluginId && entry.extension.namespace === namespace, + ); + if (!registered) { + return { ok: false, error: `unknown plugin session extension: ${pluginId}/${namespace}` }; + } + const loaded = loadPluginHostHookSessionEntry({ cfg: params.cfg, sessionKey: params.sessionKey }); + if (!loaded.entry) { + return { ok: false, error: `unknown session key: ${params.sessionKey}` }; + } + const canonicalKey = loaded.canonicalKey ?? params.sessionKey; + const nextValue = await updateSessionStore(loaded.storePath, (store) => { + const entry = store[loaded.storeKey]; + if (!entry) { + return undefined; + } + const pluginExtensions = { ...entry.pluginExtensions }; + const pluginState = { ...pluginExtensions[pluginId] }; + if (params.unset === true) { + delete pluginState[namespace]; + } else { + pluginState[namespace] = copyJsonValue(nextPluginValue); + } + if (Object.keys(pluginState).length > 0) { + pluginExtensions[pluginId] = pluginState; + } else { + delete pluginExtensions[pluginId]; + } + if (Object.keys(pluginExtensions).length > 0) { + entry.pluginExtensions = pluginExtensions; + } else { + delete entry.pluginExtensions; + } + entry.updatedAt = Date.now(); + return pluginState[namespace] as PluginJsonValue | undefined; + }); + return { ok: true, key: canonicalKey, value: nextValue }; +} + +export async function projectPluginSessionExtensions(params: { + sessionKey: string; + entry: SessionEntry; +}): Promise { + const registry = getActivePluginRegistry(); + const extensions = registry?.sessionExtensions ?? []; + if (extensions.length === 0) { + return []; + } + const projections: PluginSessionExtensionProjection[] = []; + for (const registration of extensions) { + const state = params.entry.pluginExtensions?.[registration.pluginId]?.[ + registration.extension.namespace + ] as PluginJsonValue | undefined; + if (state === undefined) { + continue; + } + const projected = projectSessionExtensionValue({ + pluginId: registration.pluginId, + namespace: registration.extension.namespace, + project: registration.extension.project, + sessionKey: params.sessionKey, + sessionId: params.entry.sessionId, + state, + }); + if (projected === PROJECTION_FAILED) { + continue; + } + if (isPromiseLike(projected)) { + discardUnexpectedPromiseProjection(projected); + continue; + } + if (projected !== undefined && isPluginJsonValue(projected)) { + // Validate the projection in both branches: with a projector the + // projector might return arbitrary values; without one the persisted + // state could be hand-edited or malformed. Always run the size + shape + // check before pushing into pluginExtensions. + projections.push({ + pluginId: registration.pluginId, + namespace: registration.extension.namespace, + value: copyJsonValue(projected), + }); + } + } + return projections; +} + +function isPromiseLike(value: unknown): value is PromiseLike { + return Boolean(value && typeof (value as { then?: unknown }).then === "function"); +} + +function discardUnexpectedPromiseProjection(value: PromiseLike): void { + void Promise.resolve(value).catch(() => undefined); +} + +function projectSessionExtensionValue(params: { + pluginId: string; + namespace: string; + project?: (ctx: { + sessionKey: string; + sessionId?: string; + state: PluginJsonValue | undefined; + }) => PluginJsonValue | undefined; + sessionKey: string; + sessionId?: string; + state: PluginJsonValue; +}): PluginJsonValue | undefined | PromiseLike | typeof PROJECTION_FAILED { + try { + return params.project + ? (params.project({ + sessionKey: params.sessionKey, + sessionId: params.sessionId, + state: params.state, + }) as PluginJsonValue | undefined | PromiseLike) + : params.state; + } catch (error) { + log.warn( + `plugin session extension projection failed: plugin=${params.pluginId} namespace=${params.namespace} error=${String(error)}`, + ); + return PROJECTION_FAILED; + } +} + +export function projectPluginSessionExtensionsSync(params: { + sessionKey: string; + entry: SessionEntry; +}): PluginSessionExtensionProjection[] { + const registry = getActivePluginRegistry(); + const extensions = registry?.sessionExtensions ?? []; + if (extensions.length === 0) { + return []; + } + const projections: PluginSessionExtensionProjection[] = []; + for (const registration of extensions) { + const state = params.entry.pluginExtensions?.[registration.pluginId]?.[ + registration.extension.namespace + ] as PluginJsonValue | undefined; + if (state === undefined) { + continue; + } + const projected = projectSessionExtensionValue({ + pluginId: registration.pluginId, + namespace: registration.extension.namespace, + project: registration.extension.project, + sessionKey: params.sessionKey, + sessionId: params.entry.sessionId, + state, + }); + if (projected === PROJECTION_FAILED) { + continue; + } + if (isPromiseLike(projected)) { + discardUnexpectedPromiseProjection(projected); + continue; + } + if (projected === undefined || !isPluginJsonValue(projected)) { + // Validate the projection regardless of whether the extension has a + // `project` function: with a projector the value can be arbitrary; + // without one the persisted state could be hand-edited or malformed. + // Either way the size + shape check should run before projection. + continue; + } + projections.push({ + pluginId: registration.pluginId, + namespace: registration.extension.namespace, + value: copyJsonValue(projected), + }); + } + return projections; +} diff --git a/src/plugins/host-hook-turn-types.ts b/src/plugins/host-hook-turn-types.ts new file mode 100644 index 00000000000..2dbf0d4bb80 --- /dev/null +++ b/src/plugins/host-hook-turn-types.ts @@ -0,0 +1,49 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import type { PluginJsonValue } from "./host-hook-json.js"; + +export type PluginNextTurnInjectionPlacement = "prepend_context" | "append_context"; + +export type PluginNextTurnInjection = { + sessionKey: string; + text: string; + idempotencyKey?: string; + placement?: PluginNextTurnInjectionPlacement; + ttlMs?: number; + metadata?: PluginJsonValue; +}; + +export type PluginNextTurnInjectionRecord = Omit & { + id: string; + pluginId: string; + pluginName?: string; + createdAt: number; + placement: PluginNextTurnInjectionPlacement; +}; + +export type PluginNextTurnInjectionEnqueueResult = { + enqueued: boolean; + id: string; + sessionKey: string; +}; + +export type PluginAgentTurnPrepareEvent = { + prompt: string; + messages: AgentMessage[] | unknown[]; + queuedInjections: PluginNextTurnInjectionRecord[]; +}; + +export type PluginAgentTurnPrepareResult = { + prependContext?: string; + appendContext?: string; +}; + +export type PluginHeartbeatPromptContributionEvent = { + sessionKey?: string; + agentId?: string; + heartbeatName?: string; +}; + +export type PluginHeartbeatPromptContributionResult = { + prependContext?: string; + appendContext?: string; +}; diff --git a/src/plugins/host-hooks.ts b/src/plugins/host-hooks.ts new file mode 100644 index 00000000000..fcb03239ffa --- /dev/null +++ b/src/plugins/host-hooks.ts @@ -0,0 +1,188 @@ +import type { OperatorScope } from "../gateway/operator-scopes.js"; +import type { AgentEventPayload, AgentEventStream } from "../infra/agent-events.js"; +import type { + PluginHookAgentContext, + PluginHookBeforeToolCallEvent, + PluginHookBeforeToolCallResult, + PluginHookToolContext, +} from "./hook-types.js"; +import type { PluginJsonValue } from "./host-hook-json.js"; +import type { + PluginAgentTurnPrepareResult, + PluginNextTurnInjectionPlacement, + PluginNextTurnInjectionRecord, +} from "./host-hook-turn-types.js"; + +export { isPluginJsonValue } from "./host-hook-json.js"; +export type { PluginJsonPrimitive, PluginJsonValue } from "./host-hook-json.js"; +export type { + PluginAgentTurnPrepareEvent, + PluginAgentTurnPrepareResult, + PluginHeartbeatPromptContributionEvent, + PluginHeartbeatPromptContributionResult, + PluginNextTurnInjection, + PluginNextTurnInjectionEnqueueResult, + PluginNextTurnInjectionPlacement, + PluginNextTurnInjectionRecord, +} from "./host-hook-turn-types.js"; + +export type PluginHostCleanupReason = "disable" | "reset" | "delete" | "restart"; + +export type PluginSessionExtensionProjectionContext = { + sessionKey: string; + sessionId?: string; + state: PluginJsonValue | undefined; +}; + +export type PluginSessionExtensionRegistration = { + namespace: string; + description: string; + project?: (ctx: PluginSessionExtensionProjectionContext) => PluginJsonValue | undefined; + cleanup?: (ctx: { reason: PluginHostCleanupReason; sessionKey?: string }) => void | Promise; +}; + +export type PluginSessionExtensionProjection = { + pluginId: string; + namespace: string; + value: PluginJsonValue; +}; + +export type PluginSessionExtensionPatchParams = { + key: string; + pluginId: string; + namespace: string; + value?: PluginJsonValue; + unset?: boolean; +}; + +export type PluginToolPolicyDecision = + | PluginHookBeforeToolCallResult + | { + allow?: boolean; + reason?: string; + }; + +export type PluginTrustedToolPolicyRegistration = { + id: string; + description: string; + evaluate: ( + event: PluginHookBeforeToolCallEvent, + ctx: PluginHookToolContext, + ) => PluginToolPolicyDecision | void | Promise; +}; + +export type PluginToolMetadataRegistration = { + toolName: string; + displayName?: string; + description?: string; + risk?: "low" | "medium" | "high"; + tags?: string[]; +}; + +export type PluginCommandContinuation = { + continueAgent?: boolean; +}; + +export type PluginControlUiDescriptor = { + id: string; + surface: "session" | "tool" | "run" | "settings"; + label: string; + description?: string; + placement?: string; + schema?: PluginJsonValue; + requiredScopes?: OperatorScope[]; +}; + +export type PluginRuntimeLifecycleRegistration = { + id: string; + description?: string; + cleanup?: (ctx: { + reason: PluginHostCleanupReason; + sessionKey?: string; + runId?: string; + }) => void | Promise; +}; + +export type PluginAgentEventSubscriptionRegistration = { + id: string; + description?: string; + streams?: AgentEventStream[]; + handle: ( + event: AgentEventPayload, + ctx: { + // oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Run-context JSON reads are caller-typed by namespace. + getRunContext: ( + namespace: string, + ) => T | undefined; + setRunContext: (namespace: string, value: PluginJsonValue) => void; + clearRunContext: (namespace?: string) => void; + }, + ) => void | Promise; +}; + +export type PluginRunContextPatch = { + runId: string; + namespace: string; + value?: PluginJsonValue; + unset?: boolean; +}; + +export type PluginRunContextGetParams = { + runId: string; + namespace: string; +}; + +export type PluginSessionSchedulerJobRegistration = { + id: string; + sessionKey: string; + kind: string; + description?: string; + cleanup?: (ctx: { + reason: PluginHostCleanupReason; + sessionKey: string; + jobId: string; + }) => void | Promise; +}; + +export type PluginSessionSchedulerJobHandle = { + id: string; + pluginId: string; + sessionKey: string; + kind: string; +}; + +export function normalizePluginHostHookId(value: string | undefined): string { + return (value ?? "").trim(); +} + +function normalizeQueuedInjectionText( + entry: PluginNextTurnInjectionRecord, + placement: PluginNextTurnInjectionPlacement, +): string | undefined { + const candidate = entry as { + placement?: unknown; + text?: unknown; + }; + if (candidate.placement !== placement || typeof candidate.text !== "string") { + return undefined; + } + const text = candidate.text.trim(); + return text || undefined; +} + +export function buildPluginAgentTurnPrepareContext(params: { + queuedInjections: PluginNextTurnInjectionRecord[]; +}): PluginAgentTurnPrepareResult { + const prepend = params.queuedInjections + .map((entry) => normalizeQueuedInjectionText(entry, "prepend_context")) + .filter(Boolean); + const append = params.queuedInjections + .map((entry) => normalizeQueuedInjectionText(entry, "append_context")) + .filter(Boolean); + return { + ...(prepend.length > 0 ? { prependContext: prepend.join("\n\n") } : {}), + ...(append.length > 0 ? { appendContext: append.join("\n\n") } : {}), + }; +} + +export type PluginHostHookRunContext = PluginHookAgentContext; diff --git a/src/plugins/install.npm-spec.test.ts b/src/plugins/install.npm-spec.test.ts index 68fb3e3a5fe..540e0d8e2a5 100644 --- a/src/plugins/install.npm-spec.test.ts +++ b/src/plugins/install.npm-spec.test.ts @@ -6,7 +6,6 @@ import { expectIntegrityDriftRejected, mockNpmPackMetadataResult, } from "../test-utils/npm-spec-install-test-helpers.js"; -import { installPluginFromNpmSpec, PLUGIN_INSTALL_ERROR_CODE } from "./install.js"; import { packToArchive } from "./test-helpers/archive-fixtures.js"; import { createSuiteTempRootTracker } from "./test-helpers/fs-fixtures.js"; @@ -16,6 +15,10 @@ vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), })); +vi.resetModules(); + +const { installPluginFromNpmSpec, PLUGIN_INSTALL_ERROR_CODE } = await import("./install.js"); + const dynamicArchiveTemplatePathCache = new Map(); const suiteTempRootTracker = createSuiteTempRootTracker("openclaw-plugin-install-npm-spec"); diff --git a/src/plugins/installed-plugin-index-record-builder.ts b/src/plugins/installed-plugin-index-record-builder.ts index d2ea9d763c6..f33cd03ad9c 100644 --- a/src/plugins/installed-plugin-index-record-builder.ts +++ b/src/plugins/installed-plugin-index-record-builder.ts @@ -266,8 +266,8 @@ export function buildInstalledPluginIndexRecords(params: { if (record.enabledByDefault === true) { indexRecord.enabledByDefault = true; } - if (record.syntheticAuthRefs && record.syntheticAuthRefs.length > 0) { - indexRecord.syntheticAuthRefs = record.syntheticAuthRefs; + if (record.syntheticAuthRefs?.length) { + indexRecord.syntheticAuthRefs = [...record.syntheticAuthRefs]; } if (record.setupSource) { indexRecord.setupSource = record.setupSource; diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 31a6effc1ba..0ea045d084c 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -6362,6 +6362,47 @@ module.exports = { expect(constrainedDiagnostics).toHaveLength(1); }); + it("blocks next-turn injections when prompt injection is disabled", () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "next-turn-policy", + filename: "next-turn-policy.cjs", + body: `module.exports = { id: "next-turn-policy", register(api) { + void api.enqueueNextTurnInjection({ + sessionKey: "agent:main:main", + text: "blocked context", + }); +} };`, + }); + + const registry = loadRegistryFromSinglePlugin({ + plugin, + pluginConfig: { + allow: ["next-turn-policy"], + entries: { + "next-turn-policy": { + hooks: { + allowPromptInjection: false, + }, + }, + }, + }, + }); + + expect(registry.plugins.find((entry) => entry.id === "next-turn-policy")?.status).toBe( + "loaded", + ); + expect(registry.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pluginId: "next-turn-policy", + message: + "next-turn injection blocked by plugins.entries.next-turn-policy.hooks.allowPromptInjection=false", + }), + ]), + ); + }); + it("keeps prompt-injection typed hooks enabled by default", () => { useNoBundledPlugins(); const plugin = writePlugin({ diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 5e96aaa7476..6b69b1ec329 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -1737,6 +1737,7 @@ function createPluginRecord(params: { workspaceDir?: string; enabled: boolean; activationState?: PluginActivationState; + syntheticAuthRefs?: string[]; configSchema: boolean; contracts?: PluginManifestContracts; }): PluginRecord { @@ -1757,6 +1758,7 @@ function createPluginRecord(params: { activated: params.activationState?.activated, activationSource: params.activationState?.source, activationReason: params.activationState?.reason, + syntheticAuthRefs: params.syntheticAuthRefs ?? [], status: params.enabled ? "loaded" : "disabled", toolNames: [], hookNames: [], @@ -2407,6 +2409,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi workspaceDir: candidate.workspaceDir, enabled: false, activationState, + syntheticAuthRefs: manifestRecord.syntheticAuthRefs, configSchema: Boolean(manifestRecord.configSchema), contracts: manifestRecord.contracts, }); @@ -2440,6 +2443,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi workspaceDir: candidate.workspaceDir, enabled: enableState.enabled, activationState, + syntheticAuthRefs: manifestRecord.syntheticAuthRefs, configSchema: Boolean(manifestRecord.configSchema), contracts: manifestRecord.contracts, }); @@ -3312,6 +3316,7 @@ export async function loadOpenClawPluginCliRegistry( workspaceDir: candidate.workspaceDir, enabled: false, activationState, + syntheticAuthRefs: manifestRecord.syntheticAuthRefs, configSchema: Boolean(manifestRecord.configSchema), contracts: manifestRecord.contracts, }); @@ -3345,6 +3350,7 @@ export async function loadOpenClawPluginCliRegistry( workspaceDir: candidate.workspaceDir, enabled: enableState.enabled, activationState, + syntheticAuthRefs: manifestRecord.syntheticAuthRefs, configSchema: Boolean(manifestRecord.configSchema), contracts: manifestRecord.contracts, }); diff --git a/src/plugins/provider-auth-choices.test.ts b/src/plugins/provider-auth-choices.test.ts index b8d8827fe01..7c66aab3a15 100644 --- a/src/plugins/provider-auth-choices.test.ts +++ b/src/plugins/provider-auth-choices.test.ts @@ -17,13 +17,23 @@ vi.mock("./plugin-registry.js", () => ({ loadPluginRegistrySnapshot: pluginRegistryMocks.loadPluginRegistrySnapshot, })); -import { +vi.mock("../plugins/plugin-registry.js", () => ({ + loadPluginManifestRegistryForPluginRegistry: + pluginRegistryMocks.loadPluginManifestRegistryForPluginRegistry, + loadPluginRegistrySnapshot: pluginRegistryMocks.loadPluginRegistrySnapshot, +})); + +vi.resetModules(); + +const { resolveManifestDeprecatedProviderAuthChoice, resolveManifestProviderApiKeyChoice, resolveManifestProviderAuthChoice, resolveManifestProviderAuthChoices, resolveManifestProviderOnboardAuthFlags, -} from "./provider-auth-choices.js"; +} = await import("./provider-auth-choices.js"); +const { resetProviderAuthAliasMapCacheForTest, resolveProviderIdForAuth } = + await import("../agents/provider-auth-aliases.js"); function createManifestPlugin(id: string, providerAuthChoices: Array>) { return { @@ -78,6 +88,7 @@ describe("provider auth choice manifest helpers", () => { }); pluginRegistryMocks.loadPluginRegistrySnapshot.mockReset(); pluginRegistryMocks.loadPluginRegistrySnapshot.mockReturnValue({ plugins: [] }); + resetProviderAuthAliasMapCacheForTest(); }); it("flattens manifest auth choices", () => { @@ -560,6 +571,9 @@ describe("provider auth choice manifest helpers", () => { }, ]); + const resolvedProviderId = resolveProviderIdForAuth("fixture-provider-plan"); + expect(pluginRegistryMocks.loadPluginManifestRegistryForPluginRegistry).toHaveBeenCalled(); + expect(resolvedProviderId).toBe("fixture-provider"); expect( resolveManifestProviderApiKeyChoice({ providerId: "fixture-provider-plan", diff --git a/src/plugins/public-surface-loader.test.ts b/src/plugins/public-surface-loader.test.ts index 9feb5bb0529..a0d91d32dcd 100644 --- a/src/plugins/public-surface-loader.test.ts +++ b/src/plugins/public-surface-loader.test.ts @@ -34,6 +34,7 @@ describe("bundled plugin public surface loader", () => { createJiti, })); const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + vi.resetModules(); try { const publicSurfaceLoader = await importFreshModule< @@ -83,6 +84,7 @@ describe("bundled plugin public surface loader", () => { createRequire: vi.fn(() => requireLoader), }); }); + vi.resetModules(); const publicSurfaceLoader = await importFreshModule< typeof import("./public-surface-loader.js") @@ -110,6 +112,7 @@ describe("bundled plugin public surface loader", () => { vi.doMock("jiti", () => ({ createJiti, })); + vi.resetModules(); const publicSurfaceLoader = await importFreshModule< typeof import("./public-surface-loader.js") @@ -142,6 +145,7 @@ describe("bundled plugin public surface loader", () => { vi.doMock("jiti", () => ({ createJiti, })); + vi.resetModules(); const publicSurfaceLoader = await importFreshModule< typeof import("./public-surface-loader.js") diff --git a/src/plugins/registry-empty.ts b/src/plugins/registry-empty.ts index ace8481395e..f2dae8150ee 100644 --- a/src/plugins/registry-empty.ts +++ b/src/plugins/registry-empty.ts @@ -35,6 +35,13 @@ export function createEmptyPluginRegistry(): PluginRegistry { services: [], gatewayDiscoveryServices: [], commands: [], + sessionExtensions: [], + trustedToolPolicies: [], + toolMetadata: [], + controlUiDescriptors: [], + runtimeLifecycles: [], + agentEventSubscriptions: [], + sessionSchedulerJobs: [], conversationBindingResolvedHandlers: [], diagnostics: [], }; diff --git a/src/plugins/registry-types.ts b/src/plugins/registry-types.ts index f1421ff9ef3..5c757037456 100644 --- a/src/plugins/registry-types.ts +++ b/src/plugins/registry-types.ts @@ -10,6 +10,15 @@ import type { } from "./agent-tool-result-middleware-types.js"; import type { CodexAppServerExtensionFactory } from "./codex-app-server-extension-types.js"; import type { PluginActivationSource } from "./config-state.js"; +import type { + PluginAgentEventSubscriptionRegistration, + PluginControlUiDescriptor, + PluginRuntimeLifecycleRegistration, + PluginSessionSchedulerJobRegistration, + PluginSessionExtensionRegistration, + PluginToolMetadataRegistration, + PluginTrustedToolPolicyRegistration, +} from "./host-hooks.js"; import type { PluginBundleFormat, PluginConfigUiHint, @@ -236,6 +245,63 @@ export type PluginCommandRegistration = { rootDir?: string; }; +export type PluginSessionExtensionRegistryRegistration = { + pluginId: string; + pluginName?: string; + extension: PluginSessionExtensionRegistration; + source: string; + rootDir?: string; +}; + +export type PluginTrustedToolPolicyRegistryRegistration = { + pluginId: string; + pluginName?: string; + policy: PluginTrustedToolPolicyRegistration; + source: string; + rootDir?: string; +}; + +export type PluginToolMetadataRegistryRegistration = { + pluginId: string; + pluginName?: string; + metadata: PluginToolMetadataRegistration; + source: string; + rootDir?: string; +}; + +export type PluginControlUiDescriptorRegistryRegistration = { + pluginId: string; + pluginName?: string; + descriptor: PluginControlUiDescriptor; + source: string; + rootDir?: string; +}; + +export type PluginRuntimeLifecycleRegistryRegistration = { + pluginId: string; + pluginName?: string; + lifecycle: PluginRuntimeLifecycleRegistration; + source: string; + rootDir?: string; +}; + +export type PluginAgentEventSubscriptionRegistryRegistration = { + pluginId: string; + pluginName?: string; + subscription: PluginAgentEventSubscriptionRegistration; + source: string; + rootDir?: string; +}; + +export type PluginSessionSchedulerJobRegistryRegistration = { + pluginId: string; + pluginName?: string; + job: PluginSessionSchedulerJobRegistration; + generation?: number; + source: string; + rootDir?: string; +}; + export type PluginConversationBindingResolvedHandlerRegistration = { pluginId: string; pluginName?: string; @@ -273,6 +339,7 @@ export type PluginRecord = { channelIds: string[]; cliBackendIds: string[]; providerIds: string[]; + syntheticAuthRefs?: string[]; speechProviderIds: string[]; realtimeTranscriptionProviderIds: string[]; realtimeVoiceProviderIds: string[]; @@ -334,6 +401,13 @@ export type PluginRegistry = { services: PluginServiceRegistration[]; gatewayDiscoveryServices: PluginGatewayDiscoveryServiceRegistration[]; commands: PluginCommandRegistration[]; + sessionExtensions?: PluginSessionExtensionRegistryRegistration[]; + trustedToolPolicies?: PluginTrustedToolPolicyRegistryRegistration[]; + toolMetadata?: PluginToolMetadataRegistryRegistration[]; + controlUiDescriptors?: PluginControlUiDescriptorRegistryRegistration[]; + runtimeLifecycles?: PluginRuntimeLifecycleRegistryRegistration[]; + agentEventSubscriptions?: PluginAgentEventSubscriptionRegistryRegistration[]; + sessionSchedulerJobs?: PluginSessionSchedulerJobRegistryRegistration[]; conversationBindingResolvedHandlers: PluginConversationBindingResolvedHandlerRegistration[]; diagnostics: PluginDiagnostic[]; }; diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 085e930151d..2beecd45306 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -10,11 +10,12 @@ import { normalizeCommandDescriptorName, sanitizeCommandDescriptorDescription, } from "../cli/program/command-descriptor-utils.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { clearContextEnginesForOwner, registerContextEngineForOwner, } from "../context-engine/registry.js"; -import type { OperatorScope } from "../gateway/operator-scopes.js"; +import { isOperatorScope, type OperatorScope } from "../gateway/operator-scopes.js"; import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; import { registerInternalHook, unregisterInternalHook } from "../hooks/internal-hooks.js"; import type { HookEntry } from "../hooks/types.js"; @@ -40,12 +41,35 @@ import { buildPluginApi } from "./api-builder.js"; import { normalizeRegisteredChannelPlugin } from "./channel-validation.js"; import { CODEX_APP_SERVER_EXTENSION_RUNTIME_ID } from "./codex-app-server-extension-factory.js"; import type { CodexAppServerExtensionFactory } from "./codex-app-server-extension-types.js"; -import { registerPluginCommand, validatePluginCommandDefinition } from "./command-registration.js"; +import { + isReservedCommandName, + registerPluginCommand, + validatePluginCommandDefinition, +} from "./command-registration.js"; import { clearPluginCommandsForPlugin } from "./command-registry-state.js"; import { getRegisteredCompactionProvider, registerCompactionProvider, } from "./compaction-provider.js"; +import { + clearPluginRunContext, + getPluginRunContext, + getPluginSessionSchedulerJobGeneration, + registerPluginSessionSchedulerJob, + setPluginRunContext, +} from "./host-hook-runtime.js"; +import { enqueuePluginNextTurnInjection } from "./host-hook-state.js"; +import { + isPluginJsonValue, + normalizePluginHostHookId, + type PluginAgentEventSubscriptionRegistration, + type PluginControlUiDescriptor, + type PluginRuntimeLifecycleRegistration, + type PluginSessionSchedulerJobRegistration, + type PluginSessionExtensionRegistration, + type PluginToolMetadataRegistration, + type PluginTrustedToolPolicyRegistration, +} from "./host-hooks.js"; import { normalizePluginHttpPath } from "./http-path.js"; import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js"; import { @@ -71,6 +95,7 @@ import type { PluginCliBackendRegistration, PluginCliRegistration, PluginCommandRegistration, + PluginControlUiDescriptorRegistryRegistration, PluginConversationBindingResolvedHandlerRegistration, PluginHookRegistration, PluginHttpRouteRegistration as RegistryTypesPluginHttpRouteRegistration, @@ -82,9 +107,13 @@ import type { PluginRegistry, PluginRegistryParams, PluginReloadRegistration, + PluginRuntimeLifecycleRegistryRegistration, PluginSecurityAuditCollectorRegistration, PluginServiceRegistration, + PluginSessionExtensionRegistryRegistration, PluginTextTransformsRegistration, + PluginToolMetadataRegistryRegistration, + PluginTrustedToolPolicyRegistryRegistration, } from "./registry-types.js"; import { withPluginRuntimePluginIdScope } from "./runtime/gateway-request-scope.js"; import type { PluginRuntime } from "./runtime/types.js"; @@ -154,13 +183,18 @@ export type { PluginMemoryEmbeddingProviderRegistration, PluginNodeHostCommandRegistration, PluginProviderRegistration, + PluginControlUiDescriptorRegistryRegistration, + PluginRuntimeLifecycleRegistryRegistration, PluginRecord, PluginRegistry, PluginRegistryParams, PluginReloadRegistration, PluginSecurityAuditCollectorRegistration, PluginServiceRegistration, + PluginSessionExtensionRegistryRegistration, PluginTextTransformsRegistration, + PluginToolMetadataRegistryRegistration, + PluginTrustedToolPolicyRegistryRegistration, PluginToolRegistration, PluginSpeechProviderRegistration, PluginRealtimeTranscriptionProviderRegistration, @@ -1276,6 +1310,25 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); return; } + const allowReservedCommandNames = command.ownership === "reserved"; + if (allowReservedCommandNames && record.origin !== "bundled") { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `only bundled plugins can claim reserved command ownership: ${name}`, + }); + return; + } + if (allowReservedCommandNames && !isReservedCommandName(name)) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `reserved command ownership requires a reserved command name: ${name}`, + }); + return; + } // For snapshot (non-activating) loads, record the command locally without touching the // global plugin command registry so running gateway commands stay intact. @@ -1284,7 +1337,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { // snapshot registries are isolated and never write to the global command table. Conflicts // will surface when the plugin is loaded via the normal activation path at gateway startup. if (!registryParams.activateGlobalSideEffects) { - const validationError = validatePluginCommandDefinition(command); + const validationError = validatePluginCommandDefinition(command, { + allowReservedCommandNames, + }); if (validationError) { pushDiagnostic({ level: "error", @@ -1298,6 +1353,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { const result = registerPluginCommand(record.id, command, { pluginName: record.name, pluginRoot: record.rootDir, + allowReservedCommandNames, }); if (!result.ok) { pushDiagnostic({ @@ -1320,6 +1376,448 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); }; + const normalizeHostHookString = (value: unknown): string => + typeof value === "string" ? normalizePluginHostHookId(value) : ""; + + const normalizeOptionalHostHookString = (value: unknown): string | undefined => { + if (value === undefined) { + return undefined; + } + if (typeof value !== "string") { + return ""; + } + return value.trim(); + }; + + const normalizeHostHookStringList = (value: unknown): string[] | undefined | null => { + if (value === undefined) { + return undefined; + } + if (!Array.isArray(value)) { + return null; + } + const normalized = value.map((item) => normalizeOptionalHostHookString(item)); + if (normalized.some((item) => !item)) { + return null; + } + return normalized as string[]; + }; + + const controlUiSurfaces = new Set([ + "session", + "tool", + "run", + "settings", + ]); + + const registerSessionExtension = ( + record: PluginRecord, + extension: PluginSessionExtensionRegistration, + ) => { + const namespace = normalizeHostHookString(extension.namespace); + const description = normalizeHostHookString(extension.description); + const project = extension.project; + let invalidMessage: string | undefined; + if (!namespace || !description) { + invalidMessage = "session extension registration requires namespace and description"; + } else if (project !== undefined && typeof project !== "function") { + invalidMessage = "session extension projector must be a function"; + } else if (project?.constructor?.name === "AsyncFunction") { + invalidMessage = "session extension projector must be synchronous"; + } else if (extension.cleanup !== undefined && typeof extension.cleanup !== "function") { + invalidMessage = "session extension cleanup must be a function"; + } + if (invalidMessage) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: invalidMessage, + }); + return; + } + const existing = (registry.sessionExtensions ?? []).find( + (entry) => entry.pluginId === record.id && entry.extension.namespace === namespace, + ); + if (existing) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `session extension already registered: ${namespace}`, + }); + return; + } + (registry.sessionExtensions ??= []).push({ + pluginId: record.id, + pluginName: record.name, + extension: { + ...extension, + namespace, + description, + }, + source: record.source, + rootDir: record.rootDir, + }); + }; + + const registerTrustedToolPolicy = ( + record: PluginRecord, + policy: PluginTrustedToolPolicyRegistration, + ) => { + if (record.origin !== "bundled") { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: "only bundled plugins can register trusted tool policies", + }); + return; + } + const id = normalizeHostHookString(policy.id); + const description = normalizeHostHookString(policy.description); + if (!id || !description || typeof policy.evaluate !== "function") { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: "trusted tool policy registration requires id, description, and evaluate()", + }); + return; + } + const existing = (registry.trustedToolPolicies ?? []).find((entry) => entry.policy.id === id); + if (existing) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `trusted tool policy already registered: ${id} (${existing.pluginId})`, + }); + return; + } + (registry.trustedToolPolicies ??= []).push({ + pluginId: record.id, + pluginName: record.name, + policy: { + ...policy, + id, + description, + }, + source: record.source, + rootDir: record.rootDir, + }); + }; + + const registerToolMetadata = (record: PluginRecord, metadata: PluginToolMetadataRegistration) => { + const toolName = normalizeHostHookString(metadata.toolName); + if (!toolName) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: "tool metadata registration missing toolName", + }); + return; + } + // Uniqueness is scoped to (pluginId + toolName): different plugins may each + // register metadata under the same toolName for their own tools, but a given + // plugin may not register the same toolName twice. At projection time + // (tools-effective-inventory.ts, tools-catalog.ts) the metadata is matched + // back to the tool's owning pluginId so plugin-X cannot decorate plugin-Y's + // tool (or a core tool) by registering metadata with the same name. + const existing = (registry.toolMetadata ?? []).find( + (entry) => entry.pluginId === record.id && entry.metadata.toolName === toolName, + ); + if (existing) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `tool metadata already registered: ${toolName} (${existing.pluginId})`, + }); + return; + } + const displayName = normalizeOptionalHostHookString(metadata.displayName); + const description = normalizeOptionalHostHookString(metadata.description); + const tags = normalizeHostHookStringList(metadata.tags); + if ( + displayName === "" || + description === "" || + tags === null || + (metadata.risk !== undefined && !["low", "medium", "high"].includes(metadata.risk)) + ) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `tool metadata registration has invalid metadata: ${toolName}`, + }); + return; + } + (registry.toolMetadata ??= []).push({ + pluginId: record.id, + pluginName: record.name, + metadata: { + ...metadata, + toolName, + ...(displayName !== undefined ? { displayName } : {}), + ...(description !== undefined ? { description } : {}), + ...(tags !== undefined ? { tags } : {}), + }, + source: record.source, + rootDir: record.rootDir, + }); + }; + + const registerControlUiDescriptor = ( + record: PluginRecord, + descriptor: PluginControlUiDescriptor, + ) => { + const id = normalizeHostHookString(descriptor.id); + const label = normalizeHostHookString(descriptor.label); + const description = normalizeOptionalHostHookString(descriptor.description); + const placement = normalizeOptionalHostHookString(descriptor.placement); + const requiredScopes = normalizeHostHookStringList(descriptor.requiredScopes); + const surface = typeof descriptor.surface === "string" ? descriptor.surface : ""; + if ( + !id || + !label || + !controlUiSurfaces.has(surface as PluginControlUiDescriptor["surface"]) || + description === "" || + placement === "" || + requiredScopes === null + ) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: + "control UI descriptor registration requires id, surface, label, and valid optional fields", + }); + return; + } + // Validate each requiredScope against the known OperatorScope set so untyped + // (JS) plugins cannot project arbitrary strings to clients as if they were + // valid operator scopes. + if (requiredScopes !== undefined) { + const unknownScope = requiredScopes.find((scope) => !isOperatorScope(scope)); + if (unknownScope !== undefined) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `control UI descriptor requiredScopes contains unknown operator scope: ${unknownScope}`, + }); + return; + } + } + if (descriptor.schema !== undefined && !isPluginJsonValue(descriptor.schema)) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `control UI descriptor schema must be JSON-compatible: ${id}`, + }); + return; + } + const existing = (registry.controlUiDescriptors ?? []).find( + (entry) => entry.pluginId === record.id && entry.descriptor.id === id, + ); + if (existing) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `control UI descriptor already registered: ${id}`, + }); + return; + } + (registry.controlUiDescriptors ??= []).push({ + pluginId: record.id, + pluginName: record.name, + descriptor: { + ...descriptor, + id, + surface: surface as PluginControlUiDescriptor["surface"], + label, + ...(description !== undefined ? { description } : {}), + ...(placement !== undefined ? { placement } : {}), + ...(requiredScopes !== undefined + ? { requiredScopes: requiredScopes as OperatorScope[] } + : {}), + }, + source: record.source, + rootDir: record.rootDir, + }); + }; + + const registerRuntimeLifecycle = ( + record: PluginRecord, + lifecycle: PluginRuntimeLifecycleRegistration, + ) => { + const id = normalizePluginHostHookId(lifecycle.id); + if (!id) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: "runtime lifecycle registration missing id", + }); + return; + } + const existing = (registry.runtimeLifecycles ?? []).find( + (entry) => entry.pluginId === record.id && entry.lifecycle.id === id, + ); + if (existing) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `runtime lifecycle already registered: ${id}`, + }); + return; + } + if (lifecycle.cleanup !== undefined && typeof lifecycle.cleanup !== "function") { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `runtime lifecycle cleanup must be a function: ${id}`, + }); + return; + } + (registry.runtimeLifecycles ??= []).push({ + pluginId: record.id, + pluginName: record.name, + lifecycle: { ...lifecycle, id }, + source: record.source, + rootDir: record.rootDir, + }); + }; + + const registerAgentEventSubscription = ( + record: PluginRecord, + subscription: PluginAgentEventSubscriptionRegistration, + ) => { + const id = normalizePluginHostHookId(subscription.id); + if (!id || typeof subscription.handle !== "function") { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: "agent event subscription registration requires id and handle", + }); + return; + } + const streams = normalizeHostHookStringList(subscription.streams); + if (streams === null) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `agent event subscription streams must be an array of strings: ${id}`, + }); + return; + } + const existing = (registry.agentEventSubscriptions ?? []).find( + (entry) => entry.pluginId === record.id && entry.subscription.id === id, + ); + if (existing) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `agent event subscription already registered: ${id}`, + }); + return; + } + (registry.agentEventSubscriptions ??= []).push({ + pluginId: record.id, + pluginName: record.name, + subscription: { ...subscription, id, ...(streams !== undefined ? { streams } : {}) }, + source: record.source, + rootDir: record.rootDir, + }); + }; + + const registerSessionSchedulerJob = ( + record: PluginRecord, + job: PluginSessionSchedulerJobRegistration, + ) => { + const jobId = normalizeHostHookString(job.id); + const sessionKey = normalizeHostHookString(job.sessionKey); + const kind = normalizeHostHookString(job.kind); + if ( + jobId && + (registry.sessionSchedulerJobs ?? []).some( + (entry) => entry.pluginId === record.id && entry.job.id === jobId, + ) + ) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `session scheduler job already registered: ${jobId}`, + }); + return undefined; + } + if (!jobId || !sessionKey || !kind) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: "session scheduler job registration requires unique id, sessionKey, and kind", + }); + return undefined; + } + if (job.cleanup !== undefined && typeof job.cleanup !== "function") { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `session scheduler job cleanup must be a function: ${jobId}`, + }); + return undefined; + } + if (registryParams.activateGlobalSideEffects === false) { + (registry.sessionSchedulerJobs ??= []).push({ + pluginId: record.id, + pluginName: record.name, + job: { ...job, id: jobId, sessionKey, kind }, + source: record.source, + rootDir: record.rootDir, + }); + return { id: jobId, pluginId: record.id, sessionKey, kind }; + } + const handle = registerPluginSessionSchedulerJob({ + pluginId: record.id, + pluginName: record.name, + job: { ...job, id: jobId, sessionKey, kind }, + }); + if (!handle) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: "session scheduler job registration requires unique id, sessionKey, and kind", + }); + return undefined; + } + (registry.sessionSchedulerJobs ??= []).push({ + pluginId: record.id, + pluginName: record.name, + job: { ...job, id: handle.id, sessionKey: handle.sessionKey, kind: handle.kind }, + generation: getPluginSessionSchedulerJobGeneration({ + pluginId: record.id, + jobId: handle.id, + sessionKey: handle.sessionKey, + }), + source: record.source, + rootDir: record.rootDir, + }); + return handle; + }; + const registerTypedHook = ( record: PluginRecord, hookName: K, @@ -1338,7 +1836,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { } let effectiveHandler = handler; if (policy?.allowPromptInjection === false && isPromptInjectionHookName(hookName)) { - if (hookName === "before_prompt_build") { + if (hookName !== "before_agent_start") { pushDiagnostic({ level: "warn", pluginId: record.id, @@ -1347,17 +1845,15 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); return; } - if (hookName === "before_agent_start") { - pushDiagnostic({ - level: "warn", - pluginId: record.id, - source: record.source, - message: `typed hook "${hookName}" prompt fields constrained by plugins.entries.${record.id}.hooks.allowPromptInjection=false`, - }); - effectiveHandler = constrainLegacyPromptInjectionHook( - handler as PluginHookHandlerMap["before_agent_start"], - ) as PluginHookHandlerMap[K]; - } + pushDiagnostic({ + level: "warn", + pluginId: record.id, + source: record.source, + message: `typed hook "${hookName}" prompt fields constrained by plugins.entries.${record.id}.hooks.allowPromptInjection=false`, + }); + effectiveHandler = constrainLegacyPromptInjectionHook( + handler as PluginHookHandlerMap["before_agent_start"], + ) as PluginHookHandlerMap[K]; } if (isConversationHookName(hookName)) { const explicitConversationAccess = policy?.allowConversationAccess; @@ -1583,6 +2079,44 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerAgentToolResultMiddleware: (handler, options) => { registerAgentToolResultMiddleware(record, handler, options); }, + registerSessionExtension: (extension) => registerSessionExtension(record, extension), + enqueueNextTurnInjection: (injection) => { + if (params.hookPolicy?.allowPromptInjection === false) { + pushDiagnostic({ + level: "warn", + pluginId: record.id, + source: record.source, + message: `next-turn injection blocked by plugins.entries.${record.id}.hooks.allowPromptInjection=false`, + }); + return Promise.resolve({ + enqueued: false, + id: "", + sessionKey: injection.sessionKey, + }); + } + return enqueuePluginNextTurnInjection({ + cfg: registryParams.runtime.config.current() as OpenClawConfig, + pluginId: record.id, + pluginName: record.name, + injection, + }); + }, + registerTrustedToolPolicy: (policy) => registerTrustedToolPolicy(record, policy), + registerToolMetadata: (metadata) => registerToolMetadata(record, metadata), + registerControlUiDescriptor: (descriptor) => + registerControlUiDescriptor(record, descriptor), + registerRuntimeLifecycle: (lifecycle) => registerRuntimeLifecycle(record, lifecycle), + registerAgentEventSubscription: (subscription) => + registerAgentEventSubscription(record, subscription), + setRunContext: (patch) => setPluginRunContext({ pluginId: record.id, patch }), + getRunContext: (get) => getPluginRunContext({ pluginId: record.id, get }), + clearRunContext: (params) => + clearPluginRunContext({ + pluginId: record.id, + runId: params.runId, + namespace: params.namespace, + }), + registerSessionSchedulerJob: (job) => registerSessionSchedulerJob(record, job), registerMemoryCapability: (capability) => { if (!hasKind(record.kind, "memory")) { throwRegistrationError("only memory plugins can register a memory capability"); @@ -1790,6 +2324,13 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerSecurityAuditCollector, registerService, registerCommand, + registerSessionExtension, + registerTrustedToolPolicy, + registerToolMetadata, + registerControlUiDescriptor, + registerRuntimeLifecycle, + registerAgentEventSubscription, + registerSessionSchedulerJob, registerHook, registerTypedHook, }; diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts index 18f350cdedf..0b2e6201b82 100644 --- a/src/plugins/runtime.ts +++ b/src/plugins/runtime.ts @@ -1,3 +1,9 @@ +import { onAgentEvent } from "../infra/agent-events.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { + clearPluginHostRuntimeState, + dispatchPluginAgentEventSubscriptions, +} from "./host-hook-runtime.js"; import { createEmptyPluginRegistry } from "./registry-empty.js"; import type { PluginRegistry } from "./registry-types.js"; import { @@ -6,6 +12,8 @@ import { type RegistrySurfaceState, } from "./runtime-state.js"; +const log = createSubsystemLogger("plugins/runtime"); + function asPluginRegistry(registry: RegistryState["activeRegistry"]): PluginRegistry | null { return registry; } @@ -39,6 +47,47 @@ const state: RegistryState = (() => { return registryState; })(); +let pluginAgentEventUnsubscribe: (() => void) | undefined; + +function registryHasPluginHostCleanupWork(registry: PluginRegistry | null): boolean { + if (!registry) { + return false; + } + return ( + registry.plugins.some((plugin) => plugin.status === "loaded") || + (registry.sessionExtensions?.length ?? 0) > 0 || + (registry.runtimeLifecycles?.length ?? 0) > 0 || + (registry.agentEventSubscriptions?.length ?? 0) > 0 || + (registry.sessionSchedulerJobs?.length ?? 0) > 0 + ); +} + +async function cleanupPreviousPluginHostRegistry(params: { + previousRegistry: PluginRegistry; + nextRegistry: PluginRegistry; +}): Promise { + const [{ getRuntimeConfig }, { cleanupReplacedPluginHostRegistry }] = await Promise.all([ + import("../config/config.js"), + import("./host-hook-cleanup.js"), + ]); + await cleanupReplacedPluginHostRegistry({ + cfg: getRuntimeConfig(), + previousRegistry: params.previousRegistry, + nextRegistry: params.nextRegistry, + }); +} + +function syncPluginAgentEventBridge(registry: PluginRegistry | null): void { + pluginAgentEventUnsubscribe?.(); + pluginAgentEventUnsubscribe = undefined; + if (!registry) { + return; + } + pluginAgentEventUnsubscribe = onAgentEvent((event) => { + dispatchPluginAgentEventSubscriptions({ registry: state.activeRegistry, event }); + }); +} + export function recordImportedPluginId(pluginId: string): void { state.importedPluginIds.add(pluginId); } @@ -79,6 +128,7 @@ export function setActivePluginRegistry( runtimeSubagentMode: "default" | "explicit" | "gateway-bindable" = "default", workspaceDir?: string, ) { + const previousRegistry = asPluginRegistry(state.activeRegistry); state.activeRegistry = registry; state.activeVersion += 1; syncTrackedSurface(state.httpRoute, registry, true); @@ -86,6 +136,20 @@ export function setActivePluginRegistry( state.key = cacheKey ?? null; state.workspaceDir = workspaceDir ?? null; state.runtimeSubagentMode = runtimeSubagentMode; + syncPluginAgentEventBridge(registry); + if ( + !previousRegistry || + previousRegistry === registry || + !registryHasPluginHostCleanupWork(previousRegistry) + ) { + return; + } + void cleanupPreviousPluginHostRegistry({ + previousRegistry, + nextRegistry: registry, + }).catch((error) => { + log.warn(`plugin host registry cleanup failed: ${String(error)}`); + }); } export function getActivePluginRegistry(): PluginRegistry | null { @@ -238,4 +302,10 @@ export function resetPluginRuntimeStateForTest(): void { state.workspaceDir = null; state.runtimeSubagentMode = "default"; state.importedPluginIds.clear(); + syncPluginAgentEventBridge(null); + // Also clear the plugin host-hook runtime singleton (run context map, + // scheduler-job records, pending agent-event handlers, closedRunIds set). + // Otherwise per-test bleed-over of those globals can cause flaky behavior + // since this helper is widely used across plugin/agent tests. + clearPluginHostRuntimeState(); } diff --git a/src/plugins/status.test-helpers.ts b/src/plugins/status.test-helpers.ts index acf64cb91ee..625357bc4a9 100644 --- a/src/plugins/status.test-helpers.ts +++ b/src/plugins/status.test-helpers.ts @@ -146,6 +146,13 @@ export function createPluginLoadResult( cliRegistrars: [], services: [], commands: [], + sessionExtensions: [], + trustedToolPolicies: [], + toolMetadata: [], + controlUiDescriptors: [], + runtimeLifecycles: [], + agentEventSubscriptions: [], + sessionSchedulerJobs: [], conversationBindingResolvedHandlers: [], ...rest, gatewayDiscoveryServices: rest.gatewayDiscoveryServices ?? [], diff --git a/src/plugins/status.ts b/src/plugins/status.ts index 02a1067d68c..49687ff1b45 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -177,6 +177,7 @@ function buildPluginRecordFromInstalledIndex( rootDir: plugin.rootDir, origin: plugin.origin, enabled: plugin.enabled, + syntheticAuthRefs: [...(plugin.syntheticAuthRefs ?? manifest?.syntheticAuthRefs ?? [])], status: plugin.enabled ? "loaded" : "disabled", toolNames: [], hookNames: [], diff --git a/src/plugins/synthetic-auth.runtime.test.ts b/src/plugins/synthetic-auth.runtime.test.ts index f546932aa81..fb215504b13 100644 --- a/src/plugins/synthetic-auth.runtime.test.ts +++ b/src/plugins/synthetic-auth.runtime.test.ts @@ -35,7 +35,7 @@ describe("synthetic auth runtime refs", () => { getPluginRegistryState.mockReset(); pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockReset().mockReturnValue({ source: "persisted", - snapshot: { plugins: [] }, + snapshot: { plugins: [] as Array<{ syntheticAuthRefs?: string[] }> }, diagnostics: [], }); }); diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index 0b3fa715a99..f4cbee7b30f 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -20,6 +20,7 @@ vi.mock("../config/plugin-auto-enable.js", () => ({ })); let resolvePluginTools: typeof import("./tools.js").resolvePluginTools; +let buildPluginToolMetadataKey: typeof import("./tools.js").buildPluginToolMetadataKey; let resetPluginRuntimeStateForTest: typeof import("./runtime.js").resetPluginRuntimeStateForTest; let setActivePluginRegistry: typeof import("./runtime.js").setActivePluginRegistry; @@ -205,7 +206,7 @@ function expectConflictingCoreNameResolution(params: { describe("resolvePluginTools optional tools", () => { beforeAll(async () => { - ({ resolvePluginTools } = await import("./tools.js")); + ({ buildPluginToolMetadataKey, resolvePluginTools } = await import("./tools.js")); ({ resetPluginRuntimeStateForTest, setActivePluginRegistry } = await import("./runtime.js")); }); @@ -463,3 +464,18 @@ describe("resolvePluginTools optional tools", () => { ); }); }); + +describe("buildPluginToolMetadataKey", () => { + beforeAll(async () => { + ({ buildPluginToolMetadataKey } = await import("./tools.js")); + }); + + it("does not collide when ids or names contain separator-like characters", () => { + expect(buildPluginToolMetadataKey("plugin", "a\uE000b")).not.toBe( + buildPluginToolMetadataKey("plugin\uE000a", "b"), + ); + expect(buildPluginToolMetadataKey("plugin", "a\u0000b")).not.toBe( + buildPluginToolMetadataKey("plugin\u0000a", "b"), + ); + }); +}); diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index 37c5957e995..f9ef9fc48cb 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -35,6 +35,13 @@ export function copyPluginToolMeta(source: AnyAgentTool, target: AnyAgentTool): } } +/** + * Builds a collision-proof key for plugin-owned tool metadata lookups. + */ +export function buildPluginToolMetadataKey(pluginId: string, toolName: string): string { + return JSON.stringify([pluginId, toolName]); +} + function normalizeAllowlist(list?: string[]) { return new Set((list ?? []).map(normalizeToolName).filter(Boolean)); } diff --git a/src/plugins/trusted-tool-policy.ts b/src/plugins/trusted-tool-policy.ts new file mode 100644 index 00000000000..4d153d0c7f3 --- /dev/null +++ b/src/plugins/trusted-tool-policy.ts @@ -0,0 +1,54 @@ +import type { + PluginHookBeforeToolCallEvent, + PluginHookBeforeToolCallResult, + PluginHookToolContext, +} from "./hook-types.js"; +import { getActivePluginRegistry } from "./runtime.js"; + +export async function runTrustedToolPolicies( + event: PluginHookBeforeToolCallEvent, + ctx: PluginHookToolContext, +): Promise { + const policies = getActivePluginRegistry()?.trustedToolPolicies ?? []; + let adjustedParams = event.params; + let hasAdjustedParams = false; + let approval: PluginHookBeforeToolCallResult["requireApproval"]; + for (const registration of policies) { + const decision = await registration.policy.evaluate({ ...event, params: adjustedParams }, ctx); + if (!decision) { + continue; + } + if ("allow" in decision && decision.allow === false) { + return { + block: true, + blockReason: decision.reason ?? `blocked by ${registration.policy.id}`, + }; + } + // `block: true` is terminal; normalize a missing blockReason to a deterministic + // reason so downstream diagnostics match the `{ allow: false }` path above. + if ("block" in decision && decision.block === true) { + return { + ...decision, + blockReason: decision.blockReason ?? `blocked by ${registration.policy.id}`, + }; + } + // `block: false` is a no-op (matches the regular `before_tool_call` hook + // pipeline) — it does NOT short-circuit the policy chain. Params and + // approvals are remembered so later trusted policies can still inspect or + // block the final call. + if ("params" in decision && decision.params) { + adjustedParams = decision.params; + hasAdjustedParams = true; + } + if ("requireApproval" in decision && decision.requireApproval && !approval) { + approval = decision.requireApproval; + } + } + if (!hasAdjustedParams && !approval) { + return undefined; + } + return { + ...(hasAdjustedParams ? { params: adjustedParams } : {}), + ...(approval ? { requireApproval: approval } : {}), + }; +} diff --git a/src/plugins/types.ts b/src/plugins/types.ts index d45d432ceb1..a388254f117 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -98,6 +98,22 @@ import type { PluginConversationBindingResolutionDecision, } from "./conversation-binding.types.js"; import type { PluginHookHandlerMap, PluginHookName } from "./hook-types.js"; +import type { + PluginAgentEventSubscriptionRegistration, + PluginControlUiDescriptor, + PluginJsonValue, + PluginNextTurnInjection, + PluginNextTurnInjectionEnqueueResult, + PluginNextTurnInjectionRecord, + PluginRunContextGetParams, + PluginRunContextPatch, + PluginRuntimeLifecycleRegistration, + PluginSessionSchedulerJobHandle, + PluginSessionSchedulerJobRegistration, + PluginSessionExtensionRegistration, + PluginToolMetadataRegistration, + PluginTrustedToolPolicyRegistration, +} from "./host-hooks.js"; import type { PluginBundleFormat, PluginConfigUiHint, @@ -183,6 +199,27 @@ export type { PluginTextTransforms, } from "./cli-backend.types.js"; export * from "./hook-types.js"; +export type { + PluginAgentEventSubscriptionRegistration, + PluginAgentTurnPrepareEvent, + PluginAgentTurnPrepareResult, + PluginControlUiDescriptor, + PluginHeartbeatPromptContributionEvent, + PluginHeartbeatPromptContributionResult, + PluginJsonValue, + PluginNextTurnInjection, + PluginNextTurnInjectionEnqueueResult, + PluginNextTurnInjectionRecord, + PluginRunContextGetParams, + PluginRunContextPatch, + PluginRuntimeLifecycleRegistration, + PluginSessionSchedulerJobHandle, + PluginSessionSchedulerJobRegistration, + PluginSessionExtensionRegistration, + PluginSessionExtensionProjection, + PluginToolMetadataRegistration, + PluginTrustedToolPolicyRegistration, +} from "./host-hooks.js"; export type ProviderAuthOptionBag = { token?: string; @@ -1849,7 +1886,7 @@ export type PluginCommandContext = { /** * Result returned by a plugin command handler. */ -export type PluginCommandResult = ReplyPayload; +export type PluginCommandResult = ReplyPayload & { continueAgent?: boolean }; /** * Handler function for plugin commands. @@ -1886,6 +1923,13 @@ export type OpenClawPluginCommandDefinition = { acceptsArgs?: boolean; /** Whether only authorized senders can use this command (default: true) */ requireAuth?: boolean; + /** Gateway operator scopes required when invoked through an internal gateway client. */ + requiredScopes?: OperatorScope[]; + /** + * Allows a bundled plugin to claim a command name that is otherwise reserved + * by core. External plugins cannot use this field. + */ + ownership?: "plugin" | "reserved"; /** The handler function */ handler: PluginCommandHandler; }; @@ -2292,6 +2336,42 @@ export type OpenClawPluginApi = { handler: AgentToolResultMiddleware, options?: AgentToolResultMiddlewareOptions, ) => void; + /** Register plugin-owned session state that can be projected into Gateway session rows. */ + registerSessionExtension: (extension: PluginSessionExtensionRegistration) => void; + /** Queue one plugin-owned context injection for the next agent turn in a session. */ + enqueueNextTurnInjection: ( + injection: PluginNextTurnInjection, + ) => Promise; + /** + * Register a trusted pre-tool policy. Only bundled plugins may use this + * before-tool-call policy tier. + */ + registerTrustedToolPolicy: (policy: PluginTrustedToolPolicyRegistration) => void; + /** + * Register display/policy metadata for a plugin-owned tool. Metadata is + * scoped to the (pluginId, toolName) pair at projection time, so plugins + * cannot decorate other plugins' tools or core tools through this surface. + */ + registerToolMetadata: (metadata: PluginToolMetadataRegistration) => void; + /** Register a generic Control UI contribution descriptor. */ + registerControlUiDescriptor: (descriptor: PluginControlUiDescriptor) => void; + /** Register cleanup hooks for plugin-owned host state and background work. */ + registerRuntimeLifecycle: (lifecycle: PluginRuntimeLifecycleRegistration) => void; + /** Subscribe to sanitized agent events through the host-owned plugin lifecycle. */ + registerAgentEventSubscription: (subscription: PluginAgentEventSubscriptionRegistration) => void; + /** Store namespaced, JSON-compatible data for the active run. Cleared on run end/error. */ + setRunContext: (patch: PluginRunContextPatch) => boolean; + /** Read namespaced plugin data for a run. */ + // oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Run-context JSON reads are caller-typed by namespace. + getRunContext: ( + params: PluginRunContextGetParams, + ) => T | undefined; + /** Clear one namespace or all namespaces this plugin owns for a run. */ + clearRunContext: (params: { runId: string; namespace?: string }) => void; + /** Register a plugin-owned session job so reset/delete/disable can clean it deterministically. */ + registerSessionSchedulerJob: ( + job: PluginSessionSchedulerJobRegistration, + ) => PluginSessionSchedulerJobHandle | undefined; /** Register the active detached task runtime for this plugin (exclusive slot). */ registerDetachedTaskRuntime: ( runtime: import("./runtime/runtime-tasks.types.js").DetachedTaskLifecycleRuntime, diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 8b99e90f2bf..7079d480e75 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -44,6 +44,8 @@ vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), })); +vi.resetModules(); + const { syncPluginsForUpdateChannel, updateNpmInstalledPlugins } = await import("./update.js"); function createSuccessfulNpmUpdateResult(params?: { diff --git a/src/wizard/setup.plugin-config.test.ts b/src/wizard/setup.plugin-config.test.ts index 55aecc0f31d..f6d20517b5f 100644 --- a/src/wizard/setup.plugin-config.test.ts +++ b/src/wizard/setup.plugin-config.test.ts @@ -14,6 +14,10 @@ vi.mock("../plugins/manifest-registry.js", () => ({ loadPluginManifestRegistry, })); +vi.mock("../plugins/plugin-registry.js", () => ({ + loadPluginManifestRegistryForPluginRegistry: loadPluginManifestRegistry, +})); + function makeManifestPlugin( id: string, uiHints?: Record, diff --git a/test/scripts/lint-suppressions.test.ts b/test/scripts/lint-suppressions.test.ts index 58c4ebd5250..f97ee39279b 100644 --- a/test/scripts/lint-suppressions.test.ts +++ b/test/scripts/lint-suppressions.test.ts @@ -115,11 +115,14 @@ describe("production lint suppressions", () => { "src/plugin-sdk/facade-runtime.ts|typescript/no-unnecessary-type-parameters|3", "src/plugin-sdk/qa-runner-runtime.ts|typescript/no-unnecessary-type-parameters|1", "src/plugins/hooks.ts|typescript/no-unnecessary-type-parameters|1", + "src/plugins/host-hook-runtime.ts|typescript/no-unnecessary-type-parameters|2", + "src/plugins/host-hooks.ts|typescript/no-unnecessary-type-parameters|1", "src/plugins/lazy-service-module.ts|typescript/no-unnecessary-type-parameters|1", "src/plugins/public-surface-loader.ts|typescript/no-unnecessary-type-parameters|1", "src/plugins/runtime/runtime-channel.ts|typescript/no-unnecessary-type-parameters|1", "src/plugins/runtime/runtime-plugin-boundary.ts|typescript/no-unnecessary-type-parameters|2", "src/plugins/runtime/types-channel.ts|typescript/no-unnecessary-type-parameters|1", + "src/plugins/types.ts|typescript/no-unnecessary-type-parameters|1", "src/tasks/task-flow-registry.store.sqlite.ts|typescript/no-unnecessary-type-parameters|1", "src/tasks/task-registry.store.sqlite.ts|typescript/no-unnecessary-type-parameters|1", "src/test-utils/bundled-plugin-public-surface.ts|typescript/no-unnecessary-type-parameters|2",