mirror of https://github.com/openclaw/openclaw.git
feat(hooks): add async requireApproval to before_tool_call (#55339)
* Plugins: add native ask dialog for before_tool_call hooks Extend the before_tool_call plugin hook with a requireApproval return field that pauses agent execution and waits for real user approval via channels (Telegram, Discord, /approve command) instead of relying on the agent to cooperate with a soft block. - Add requireApproval field to PluginHookBeforeToolCallResult with id, title, description, severity, timeout, and timeoutBehavior options - Extend runModifyingHook merge callback to receive hook registration so mergers can stamp pluginId; always invoke merger even for the first result - Make ExecApprovalManager generic so it can be reused for plugin approvals - Add plugin.approval.request/waitDecision/resolve gateway methods with schemas, scope guards, and broadcast events - Handle requireApproval in pi-tools via two-phase gateway RPC with fallback to soft block when the gateway is unavailable - Extend the exec approval forwarder with plugin approval message builders and forwarding methods - Update /approve command to fall back to plugin.approval.resolve when exec approval lookup fails - Document before_tool_call requireApproval in hooks docs and unified /approve behavior in exec-approvals docs * Plugins: simplify plugin approval code - Extract mergeParamsWithApprovalOverrides helper to deduplicate param merge logic in before_tool_call hook handling - Use idiomatic conditional spread syntax in toolContext construction - Extract callApprovalMethod helper in /approve command to eliminate duplicated callGateway calls - Simplify plugin approval schema by removing unnecessary Type.Union with Type.Null on optional fields - Extract normalizeTrimmedString helper for turn source field trimming * Tests: add plugin approval wiring and /approve fallback coverage Fix 3 broken assertions expecting old "Exec approval" message text. Add tests for the /approve command's exec→plugin fallback path, plugin approval method registration and scope authorization, and handler factory key verification. * UI: wire plugin approval events into the exec approval overlay Handle plugin.approval.requested and plugin.approval.resolved gateway events by extending the existing exec approval queue with a kind discriminator. Plugin approvals reuse the same overlay, queue management, and expiry timer, with branched rendering for plugin-specific content (title, description, severity). The decision handler routes resolve calls to the correct gateway method based on kind. * fix: read plugin approval fields from nested request payload The gateway broadcasts plugin approval payloads with title, description, severity, pluginId, agentId, and sessionKey nested inside the request object (PluginApprovalRequestPayload), not at the top level. Fix the parser to read from the correct location so the overlay actually appears. * feat: invoke plugin onResolution callback after approval decision Adds onResolution to the requireApproval type and invokes it after the user resolves the approval dialog, enabling plugins to react to allow-always vs allow-once decisions. * docs: add onResolution callback to requireApproval hook documentation * test: fix /approve assertion for unified approval response text * docs: regenerate plugin SDK API baseline * docs: add changelog entry for plugin approval hooks * fix: harden plugin approval hook reliability - Add APPROVAL_NOT_FOUND error code so /approve fallback uses structured matching instead of fragile string comparison - Check block before requireApproval so higher-priority plugin blocks cannot be overridden by a lower-priority approval - Race waitDecision against abort signal so users are not stuck waiting for the full approval timeout after cancelling a run - Use null consistently for missing pluginDescription instead of converting to undefined - Add comments explaining the +10s timeout buffer on gateway RPCs * docs: document block > requireApproval precedence in hooks * fix: address Phase 1 critical correctness issues for plugin approval hooks - Fix timeout-allow param bug: return merged hook params instead of original params when timeoutBehavior is "allow", preventing security plugins from having their parameter rewrites silently discarded. - Host-generate approval IDs: remove plugin-provided id field from the requireApproval type, gateway request, and protocol schema. Server always generates IDs via randomUUID() to prevent forged/predictable ID attacks. - Define onResolution semantics: add PluginApprovalResolutions constants and PluginApprovalResolution type. onResolution callback now fires on every exit path (allow, deny, timeout, abort, gateway error, no-ID). Decision branching uses constants instead of hard-coded strings. - Fix pre-existing test infrastructure issues: bypass CJS mock cache for getGlobalHookRunner global singleton, reset gateway mock between tests, fix hook merger priority ordering in block+requireApproval test. * fix: tighten plugin approval schema and add kind-prefixed IDs Harden the plugin approval request schema: restrict severity to enum (info|warning|critical), cap timeoutMs at 600s, limit title to 80 chars and description to 256 chars. Prefix plugin approval IDs with `plugin:` so /approve routing can distinguish them from exec approvals deterministically instead of relying on fallback. * fix: address remaining PR feedback (Phases 1-3 source changes) * chore: regenerate baselines and protocol artifacts * fix: exclude requesting connection from approval-client availability check hasExecApprovalClients() counted the backend connection that issued the plugin.approval.request RPC as an approval client, preventing the no-approval-route fast path from firing in headless setups and causing 120s stalls. Pass the caller's connId so it is skipped. Applied to both plugin and exec approval handlers. * Approvals: complete Discord parity and compatibility fallback * Hooks: make plugin approval onResolution non-blocking * Hooks: freeze params after approval owner is selected * Gateway: harden plugin approval request/decision flow * Discord/Telegram: fix plugin approval delivery parity * Approvals: fix Telegram plugin approval edge cases * Auto-reply: enforce Telegram plugin approval approvers * Approvals: harden Telegram and plugin resolve policies * Agents: static-import gateway approval call and fix e2e mock loading * Auto-reply: restore /approve Telegram import boundary * Approvals: fail closed on no-route and neutralize Discord mentions * docs: refresh generated config and plugin API baselines --------- Co-authored-by: Václav Belák <vaclav.belak@gendigital.com>
This commit is contained in:
parent
351a931a62
commit
6ade9c474c
|
|
@ -154,6 +154,7 @@ Docs: https://docs.openclaw.ai
|
|||
- Agents/tools: make `/tools` show the tools the current agent can actually use right now, add a compact default view with an optional detailed mode, and add a live “Available Right Now” section in the Control UI so it is easier to see what will work before you ask.
|
||||
- Microsoft Teams: migrate to the official Teams SDK and add AI-agent UX best practices including streaming 1:1 replies, welcome cards with prompt starters, feedback/reflection, informative status updates, typing indicators, and native AI labeling. (#51808)
|
||||
- Microsoft Teams: add message edit and delete support for sent messages, including in-thread fallbacks when no explicit target is provided. (#49925)
|
||||
- Plugins/hooks: add async `requireApproval` to `before_tool_call` hooks, letting plugins pause tool execution and prompt the user for approval via the exec approval overlay, Telegram buttons, Discord interactions, or the `/approve` command on any channel. The `/approve` command now handles both exec and plugin approvals with automatic fallback.
|
||||
- Skills/install metadata: add one-click install recipes to bundled skills (coding-agent, gh-issues, openai-whisper-api, session-logs, tmux, trello, weather) so the CLI and Control UI can offer dependency installation when requirements are missing. (#53411) Thanks @BunsDev.
|
||||
- Control UI/skills: add status-filter tabs (All / Ready / Needs Setup / Disabled) with counts, replace inline skill cards with a click-to-detail dialog showing requirements, toggle switch, install action, API key entry, source metadata, and homepage link. (#53411) Thanks @BunsDev.
|
||||
- Slack/interactive replies: restore rich reply parity for direct deliveries, auto-render simple trailing `Options:` lines as buttons/selects, improve Slack interactive setup defaults, and isolate reply controls from plugin interactive handlers. (#53389) Thanks @vincentkoc.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ public enum ErrorCode: String, Codable, Sendable {
|
|||
case notPaired = "NOT_PAIRED"
|
||||
case agentTimeout = "AGENT_TIMEOUT"
|
||||
case invalidRequest = "INVALID_REQUEST"
|
||||
case approvalNotFound = "APPROVAL_NOT_FOUND"
|
||||
case unavailable = "UNAVAILABLE"
|
||||
}
|
||||
|
||||
|
|
@ -3434,6 +3435,90 @@ public struct ExecApprovalResolveParams: Codable, Sendable {
|
|||
}
|
||||
}
|
||||
|
||||
public struct PluginApprovalRequestParams: Codable, Sendable {
|
||||
public let pluginid: String?
|
||||
public let title: String
|
||||
public let description: String
|
||||
public let severity: String?
|
||||
public let toolname: String?
|
||||
public let toolcallid: String?
|
||||
public let agentid: String?
|
||||
public let sessionkey: String?
|
||||
public let turnsourcechannel: String?
|
||||
public let turnsourceto: String?
|
||||
public let turnsourceaccountid: String?
|
||||
public let turnsourcethreadid: AnyCodable?
|
||||
public let timeoutms: Int?
|
||||
public let twophase: Bool?
|
||||
|
||||
public init(
|
||||
pluginid: String?,
|
||||
title: String,
|
||||
description: String,
|
||||
severity: String?,
|
||||
toolname: String?,
|
||||
toolcallid: String?,
|
||||
agentid: String?,
|
||||
sessionkey: String?,
|
||||
turnsourcechannel: String?,
|
||||
turnsourceto: String?,
|
||||
turnsourceaccountid: String?,
|
||||
turnsourcethreadid: AnyCodable?,
|
||||
timeoutms: Int?,
|
||||
twophase: Bool?)
|
||||
{
|
||||
self.pluginid = pluginid
|
||||
self.title = title
|
||||
self.description = description
|
||||
self.severity = severity
|
||||
self.toolname = toolname
|
||||
self.toolcallid = toolcallid
|
||||
self.agentid = agentid
|
||||
self.sessionkey = sessionkey
|
||||
self.turnsourcechannel = turnsourcechannel
|
||||
self.turnsourceto = turnsourceto
|
||||
self.turnsourceaccountid = turnsourceaccountid
|
||||
self.turnsourcethreadid = turnsourcethreadid
|
||||
self.timeoutms = timeoutms
|
||||
self.twophase = twophase
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case pluginid = "pluginId"
|
||||
case title
|
||||
case description
|
||||
case severity
|
||||
case toolname = "toolName"
|
||||
case toolcallid = "toolCallId"
|
||||
case agentid = "agentId"
|
||||
case sessionkey = "sessionKey"
|
||||
case turnsourcechannel = "turnSourceChannel"
|
||||
case turnsourceto = "turnSourceTo"
|
||||
case turnsourceaccountid = "turnSourceAccountId"
|
||||
case turnsourcethreadid = "turnSourceThreadId"
|
||||
case timeoutms = "timeoutMs"
|
||||
case twophase = "twoPhase"
|
||||
}
|
||||
}
|
||||
|
||||
public struct PluginApprovalResolveParams: Codable, Sendable {
|
||||
public let id: String
|
||||
public let decision: String
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
decision: String)
|
||||
{
|
||||
self.id = id
|
||||
self.decision = decision
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case decision
|
||||
}
|
||||
}
|
||||
|
||||
public struct DevicePairListParams: Codable, Sendable {}
|
||||
|
||||
public struct DevicePairApproveParams: Codable, Sendable {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ public enum ErrorCode: String, Codable, Sendable {
|
|||
case notPaired = "NOT_PAIRED"
|
||||
case agentTimeout = "AGENT_TIMEOUT"
|
||||
case invalidRequest = "INVALID_REQUEST"
|
||||
case approvalNotFound = "APPROVAL_NOT_FOUND"
|
||||
case unavailable = "UNAVAILABLE"
|
||||
}
|
||||
|
||||
|
|
@ -3434,6 +3435,90 @@ public struct ExecApprovalResolveParams: Codable, Sendable {
|
|||
}
|
||||
}
|
||||
|
||||
public struct PluginApprovalRequestParams: Codable, Sendable {
|
||||
public let pluginid: String?
|
||||
public let title: String
|
||||
public let description: String
|
||||
public let severity: String?
|
||||
public let toolname: String?
|
||||
public let toolcallid: String?
|
||||
public let agentid: String?
|
||||
public let sessionkey: String?
|
||||
public let turnsourcechannel: String?
|
||||
public let turnsourceto: String?
|
||||
public let turnsourceaccountid: String?
|
||||
public let turnsourcethreadid: AnyCodable?
|
||||
public let timeoutms: Int?
|
||||
public let twophase: Bool?
|
||||
|
||||
public init(
|
||||
pluginid: String?,
|
||||
title: String,
|
||||
description: String,
|
||||
severity: String?,
|
||||
toolname: String?,
|
||||
toolcallid: String?,
|
||||
agentid: String?,
|
||||
sessionkey: String?,
|
||||
turnsourcechannel: String?,
|
||||
turnsourceto: String?,
|
||||
turnsourceaccountid: String?,
|
||||
turnsourcethreadid: AnyCodable?,
|
||||
timeoutms: Int?,
|
||||
twophase: Bool?)
|
||||
{
|
||||
self.pluginid = pluginid
|
||||
self.title = title
|
||||
self.description = description
|
||||
self.severity = severity
|
||||
self.toolname = toolname
|
||||
self.toolcallid = toolcallid
|
||||
self.agentid = agentid
|
||||
self.sessionkey = sessionkey
|
||||
self.turnsourcechannel = turnsourcechannel
|
||||
self.turnsourceto = turnsourceto
|
||||
self.turnsourceaccountid = turnsourceaccountid
|
||||
self.turnsourcethreadid = turnsourcethreadid
|
||||
self.timeoutms = timeoutms
|
||||
self.twophase = twophase
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case pluginid = "pluginId"
|
||||
case title
|
||||
case description
|
||||
case severity
|
||||
case toolname = "toolName"
|
||||
case toolcallid = "toolCallId"
|
||||
case agentid = "agentId"
|
||||
case sessionkey = "sessionKey"
|
||||
case turnsourcechannel = "turnSourceChannel"
|
||||
case turnsourceto = "turnSourceTo"
|
||||
case turnsourceaccountid = "turnSourceAccountId"
|
||||
case turnsourcethreadid = "turnSourceThreadId"
|
||||
case timeoutms = "timeoutMs"
|
||||
case twophase = "twoPhase"
|
||||
}
|
||||
}
|
||||
|
||||
public struct PluginApprovalResolveParams: Codable, Sendable {
|
||||
public let id: String
|
||||
public let decision: String
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
decision: String)
|
||||
{
|
||||
self.id = id
|
||||
self.decision = decision
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case decision
|
||||
}
|
||||
}
|
||||
|
||||
public struct DevicePairListParams: Codable, Sendable {}
|
||||
|
||||
public struct DevicePairApproveParams: Codable, Sendable {
|
||||
|
|
|
|||
|
|
@ -7124,7 +7124,7 @@
|
|||
"advanced"
|
||||
],
|
||||
"label": "Approvals",
|
||||
"help": "Approval routing controls for forwarding exec approval requests to chat destinations outside the originating session. Keep this disabled unless operators need explicit out-of-band approval visibility.",
|
||||
"help": "Approval routing controls for forwarding exec and plugin approval requests to chat destinations outside the originating session. Keep these disabled unless operators need explicit out-of-band approval visibility.",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
|
|
@ -7300,6 +7300,179 @@
|
|||
"help": "Destination identifier inside the target channel (channel ID, user ID, or thread root depending on provider). Verify semantics per provider because destination format differs across channel integrations.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "approvals.plugin",
|
||||
"kind": "core",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Plugin Approval Forwarding",
|
||||
"help": "Groups plugin-approval forwarding behavior including enablement, routing mode, filters, and explicit targets. Independent of exec approval forwarding. Configure here when plugin approval prompts must reach operational channels.",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "approvals.plugin.agentFilter",
|
||||
"kind": "core",
|
||||
"type": "array",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Plugin Approval Agent Filter",
|
||||
"help": "Optional allowlist of agent IDs eligible for forwarded plugin approvals, for example `[\"primary\", \"ops-agent\"]`. Use this to limit forwarding blast radius.",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "approvals.plugin.agentFilter.*",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "approvals.plugin.enabled",
|
||||
"kind": "core",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Forward Plugin Approvals",
|
||||
"help": "Enables forwarding of plugin approval requests to configured delivery destinations (default: false). Independent of approvals.exec.enabled.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "approvals.plugin.mode",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Plugin Approval Forwarding Mode",
|
||||
"help": "Controls where plugin approval prompts are sent: \"session\" uses origin chat, \"targets\" uses configured targets, and \"both\" sends to both paths.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "approvals.plugin.sessionFilter",
|
||||
"kind": "core",
|
||||
"type": "array",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"storage"
|
||||
],
|
||||
"label": "Plugin Approval Session Filter",
|
||||
"help": "Optional session-key filters matched as substring or regex-style patterns, for example `[\"discord:\", \"^agent:ops:\"]`. Use narrow patterns so only intended approval contexts are forwarded.",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "approvals.plugin.sessionFilter.*",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "approvals.plugin.targets",
|
||||
"kind": "core",
|
||||
"type": "array",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Plugin Approval Forwarding Targets",
|
||||
"help": "Explicit delivery targets used when plugin approval forwarding mode includes targets, each with channel and destination details.",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "approvals.plugin.targets.*",
|
||||
"kind": "core",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "approvals.plugin.targets.*.accountId",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Plugin Approval Target Account ID",
|
||||
"help": "Optional account selector for multi-account channel setups when plugin approvals must route through a specific account context.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "approvals.plugin.targets.*.channel",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Plugin Approval Target Channel",
|
||||
"help": "Channel/provider ID used for forwarded plugin approval delivery, such as discord, slack, or a plugin channel id.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "approvals.plugin.targets.*.threadId",
|
||||
"kind": "core",
|
||||
"type": [
|
||||
"number",
|
||||
"string"
|
||||
],
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Plugin Approval Target Thread ID",
|
||||
"help": "Optional thread/topic target for channels that support threaded delivery of forwarded plugin approvals.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "approvals.plugin.targets.*.to",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Plugin Approval Target Destination",
|
||||
"help": "Destination identifier inside the target channel (channel ID, user ID, or thread root depending on provider).",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "audio",
|
||||
"kind": "core",
|
||||
|
|
@ -49644,6 +49817,127 @@
|
|||
"help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.litellm",
|
||||
"kind": "plugin",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "@openclaw/litellm-provider",
|
||||
"help": "OpenClaw LiteLLM provider plugin (plugin: litellm)",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.litellm.config",
|
||||
"kind": "plugin",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "@openclaw/litellm-provider Config",
|
||||
"help": "Plugin-defined config payload for litellm.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.litellm.enabled",
|
||||
"kind": "plugin",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Enable @openclaw/litellm-provider",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.litellm.hooks",
|
||||
"kind": "plugin",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Plugin Hook Policy",
|
||||
"help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.litellm.hooks.allowPromptInjection",
|
||||
"kind": "plugin",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"access"
|
||||
],
|
||||
"label": "Allow Prompt Injection Hooks",
|
||||
"help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.litellm.subagent",
|
||||
"kind": "plugin",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Plugin Subagent Policy",
|
||||
"help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.litellm.subagent.allowedModels",
|
||||
"kind": "plugin",
|
||||
"type": "array",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"access"
|
||||
],
|
||||
"label": "Plugin Subagent Allowed Models",
|
||||
"help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.litellm.subagent.allowedModels.*",
|
||||
"kind": "plugin",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.litellm.subagent.allowModelOverride",
|
||||
"kind": "plugin",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"access"
|
||||
],
|
||||
"label": "Allow Plugin Subagent Model Override",
|
||||
"help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.llm-task",
|
||||
"kind": "plugin",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5532}
|
||||
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5554}
|
||||
{"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true}
|
||||
{"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true}
|
||||
{"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
|
|
@ -638,7 +638,7 @@
|
|||
{"recordType":"path","path":"agents.list.*.tools.sandbox.tools.deny","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"agents.list.*.tools.sandbox.tools.deny.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"agents.list.*.workspace","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"approvals","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Approvals","help":"Approval routing controls for forwarding exec approval requests to chat destinations outside the originating session. Keep this disabled unless operators need explicit out-of-band approval visibility.","hasChildren":true}
|
||||
{"recordType":"path","path":"approvals","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Approvals","help":"Approval routing controls for forwarding exec and plugin approval requests to chat destinations outside the originating session. Keep these disabled unless operators need explicit out-of-band approval visibility.","hasChildren":true}
|
||||
{"recordType":"path","path":"approvals.exec","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Exec Approval Forwarding","help":"Groups exec-approval forwarding behavior including enablement, routing mode, filters, and explicit targets. Configure here when approval prompts must reach operational channels instead of only the origin thread.","hasChildren":true}
|
||||
{"recordType":"path","path":"approvals.exec.agentFilter","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for forwarded approvals, for example `[\"primary\", \"ops-agent\"]`. Use this to limit forwarding blast radius and avoid notifying channels for unrelated agents.","hasChildren":true}
|
||||
{"recordType":"path","path":"approvals.exec.agentFilter.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
|
|
@ -652,6 +652,19 @@
|
|||
{"recordType":"path","path":"approvals.exec.targets.*.channel","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Approval Target Channel","help":"Channel/provider ID used for forwarded approval delivery, such as discord, slack, or a plugin channel id. Use valid channel IDs only so approvals do not silently fail due to unknown routes.","hasChildren":false}
|
||||
{"recordType":"path","path":"approvals.exec.targets.*.threadId","kind":"core","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Approval Target Thread ID","help":"Optional thread/topic target for channels that support threaded delivery of forwarded approvals. Use this to keep approval traffic contained in operational threads instead of main channels.","hasChildren":false}
|
||||
{"recordType":"path","path":"approvals.exec.targets.*.to","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Approval Target Destination","help":"Destination identifier inside the target channel (channel ID, user ID, or thread root depending on provider). Verify semantics per provider because destination format differs across channel integrations.","hasChildren":false}
|
||||
{"recordType":"path","path":"approvals.plugin","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Approval Forwarding","help":"Groups plugin-approval forwarding behavior including enablement, routing mode, filters, and explicit targets. Independent of exec approval forwarding. Configure here when plugin approval prompts must reach operational channels.","hasChildren":true}
|
||||
{"recordType":"path","path":"approvals.plugin.agentFilter","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for forwarded plugin approvals, for example `[\"primary\", \"ops-agent\"]`. Use this to limit forwarding blast radius.","hasChildren":true}
|
||||
{"recordType":"path","path":"approvals.plugin.agentFilter.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"approvals.plugin.enabled","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Forward Plugin Approvals","help":"Enables forwarding of plugin approval requests to configured delivery destinations (default: false). Independent of approvals.exec.enabled.","hasChildren":false}
|
||||
{"recordType":"path","path":"approvals.plugin.mode","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Approval Forwarding Mode","help":"Controls where plugin approval prompts are sent: \"session\" uses origin chat, \"targets\" uses configured targets, and \"both\" sends to both paths.","hasChildren":false}
|
||||
{"recordType":"path","path":"approvals.plugin.sessionFilter","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Plugin Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns, for example `[\"discord:\", \"^agent:ops:\"]`. Use narrow patterns so only intended approval contexts are forwarded.","hasChildren":true}
|
||||
{"recordType":"path","path":"approvals.plugin.sessionFilter.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"approvals.plugin.targets","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Approval Forwarding Targets","help":"Explicit delivery targets used when plugin approval forwarding mode includes targets, each with channel and destination details.","hasChildren":true}
|
||||
{"recordType":"path","path":"approvals.plugin.targets.*","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"approvals.plugin.targets.*.accountId","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Approval Target Account ID","help":"Optional account selector for multi-account channel setups when plugin approvals must route through a specific account context.","hasChildren":false}
|
||||
{"recordType":"path","path":"approvals.plugin.targets.*.channel","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Approval Target Channel","help":"Channel/provider ID used for forwarded plugin approval delivery, such as discord, slack, or a plugin channel id.","hasChildren":false}
|
||||
{"recordType":"path","path":"approvals.plugin.targets.*.threadId","kind":"core","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Approval Target Thread ID","help":"Optional thread/topic target for channels that support threaded delivery of forwarded plugin approvals.","hasChildren":false}
|
||||
{"recordType":"path","path":"approvals.plugin.targets.*.to","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Approval Target Destination","help":"Destination identifier inside the target channel (channel ID, user ID, or thread root depending on provider).","hasChildren":false}
|
||||
{"recordType":"path","path":"audio","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Audio","help":"Global audio ingestion settings used before higher-level tools process speech or media content. Configure this when you need deterministic transcription behavior for voice notes and clips.","hasChildren":true}
|
||||
{"recordType":"path","path":"audio.transcription","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["media"],"label":"Audio Transcription","help":"Command-based transcription settings for converting audio files into text before agent handling. Keep a simple, deterministic command path here so failures are easy to diagnose in logs.","hasChildren":true}
|
||||
{"recordType":"path","path":"audio.transcription.command","kind":"core","type":"array","required":true,"deprecated":false,"sensitive":false,"tags":["media"],"label":"Audio Transcription Command","help":"Executable + args used to transcribe audio (first token must be a safe binary/path), for example `[\"whisper-cli\", \"--model\", \"small\", \"{input}\"]`. Prefer a pinned command so runtime environments behave consistently.","hasChildren":true}
|
||||
|
|
@ -4313,6 +4326,15 @@
|
|||
{"recordType":"path","path":"plugins.entries.line.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.line.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.line.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.litellm","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/litellm-provider","help":"OpenClaw LiteLLM provider plugin (plugin: litellm)","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.litellm.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/litellm-provider Config","help":"Plugin-defined config payload for litellm.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.litellm.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/litellm-provider","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.litellm.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.litellm.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.litellm.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.litellm.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.litellm.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.litellm.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.llm-task","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"LLM Task","help":"Generic JSON-only LLM tool for structured tasks callable from workflows. (plugin: llm-task)","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.llm-task.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"LLM Task Config","help":"Plugin-defined config payload for llm-task.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.llm-task.config.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@
|
|||
"exportName": "ChannelConfiguredBindingConversationRef",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 554,
|
||||
"line": 569,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -118,7 +118,7 @@
|
|||
"exportName": "ChannelConfiguredBindingMatch",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 559,
|
||||
"line": 574,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -127,7 +127,7 @@
|
|||
"exportName": "ChannelConfiguredBindingProvider",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 563,
|
||||
"line": 578,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -136,7 +136,7 @@
|
|||
"exportName": "ChannelGatewayContext",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 239,
|
||||
"line": 243,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -190,7 +190,7 @@
|
|||
"exportName": "ChannelSetupAdapter",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 56,
|
||||
"line": 60,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -1707,7 +1707,7 @@
|
|||
"exportName": "ChannelAllowlistAdapter",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 498,
|
||||
"line": 513,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -1716,7 +1716,7 @@
|
|||
"exportName": "ChannelAuthAdapter",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 363,
|
||||
"line": 367,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -1734,7 +1734,7 @@
|
|||
"exportName": "ChannelCapabilitiesDiagnostics",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 47,
|
||||
"line": 51,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -1743,7 +1743,7 @@
|
|||
"exportName": "ChannelCapabilitiesDisplayLine",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 42,
|
||||
"line": 46,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -1752,7 +1752,7 @@
|
|||
"exportName": "ChannelCapabilitiesDisplayTone",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 40,
|
||||
"line": 44,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -1761,7 +1761,7 @@
|
|||
"exportName": "ChannelCommandAdapter",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 445,
|
||||
"line": 449,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -1770,7 +1770,7 @@
|
|||
"exportName": "ChannelConfigAdapter",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 91,
|
||||
"line": 95,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -1779,7 +1779,7 @@
|
|||
"exportName": "ChannelConfiguredBindingConversationRef",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 554,
|
||||
"line": 569,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -1788,7 +1788,7 @@
|
|||
"exportName": "ChannelConfiguredBindingMatch",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 559,
|
||||
"line": 574,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -1797,7 +1797,7 @@
|
|||
"exportName": "ChannelConfiguredBindingProvider",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 563,
|
||||
"line": 578,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -1806,7 +1806,7 @@
|
|||
"exportName": "ChannelDirectoryAdapter",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 407,
|
||||
"line": 411,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -1833,7 +1833,7 @@
|
|||
"exportName": "ChannelElevatedAdapter",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 438,
|
||||
"line": 442,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -1842,7 +1842,7 @@
|
|||
"exportName": "ChannelExecApprovalAdapter",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 464,
|
||||
"line": 468,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -1851,7 +1851,7 @@
|
|||
"exportName": "ChannelExecApprovalForwardTarget",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 32,
|
||||
"line": 36,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -1860,7 +1860,7 @@
|
|||
"exportName": "ChannelExecApprovalInitiatingSurfaceState",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 27,
|
||||
"line": 31,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -1869,7 +1869,7 @@
|
|||
"exportName": "ChannelGatewayAdapter",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 347,
|
||||
"line": 351,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -1878,7 +1878,7 @@
|
|||
"exportName": "ChannelGatewayContext",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 239,
|
||||
"line": 243,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -1887,7 +1887,7 @@
|
|||
"exportName": "ChannelGroupAdapter",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 122,
|
||||
"line": 126,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -1905,7 +1905,7 @@
|
|||
"exportName": "ChannelHeartbeatAdapter",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 373,
|
||||
"line": 377,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -1932,7 +1932,7 @@
|
|||
"exportName": "ChannelLifecycleAdapter",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 450,
|
||||
"line": 454,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -1941,7 +1941,7 @@
|
|||
"exportName": "ChannelLoginWithQrStartResult",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 318,
|
||||
"line": 322,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -1950,7 +1950,7 @@
|
|||
"exportName": "ChannelLoginWithQrWaitResult",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 323,
|
||||
"line": 327,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -1959,7 +1959,7 @@
|
|||
"exportName": "ChannelLogoutContext",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 328,
|
||||
"line": 332,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -1968,7 +1968,7 @@
|
|||
"exportName": "ChannelLogoutResult",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 312,
|
||||
"line": 316,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -2076,7 +2076,7 @@
|
|||
"exportName": "ChannelOutboundAdapter",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 155,
|
||||
"line": 159,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -2085,7 +2085,7 @@
|
|||
"exportName": "ChannelOutboundContext",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 128,
|
||||
"line": 132,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -2103,7 +2103,7 @@
|
|||
"exportName": "ChannelPairingAdapter",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 336,
|
||||
"line": 340,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -2139,7 +2139,7 @@
|
|||
"exportName": "ChannelResolveKind",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 418,
|
||||
"line": 422,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -2148,7 +2148,7 @@
|
|||
"exportName": "ChannelResolverAdapter",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 428,
|
||||
"line": 432,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -2157,7 +2157,7 @@
|
|||
"exportName": "ChannelResolveResult",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 420,
|
||||
"line": 424,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -2166,7 +2166,7 @@
|
|||
"exportName": "ChannelSecurityAdapter",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 576,
|
||||
"line": 591,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -2193,7 +2193,7 @@
|
|||
"exportName": "ChannelSetupAdapter",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 56,
|
||||
"line": 60,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -2211,7 +2211,7 @@
|
|||
"exportName": "ChannelStatusAdapter",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 185,
|
||||
"line": 189,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -2393,7 +2393,7 @@
|
|||
"exportName": "formatDocsLink",
|
||||
"kind": "function",
|
||||
"source": {
|
||||
"line": 5,
|
||||
"line": 9,
|
||||
"path": "src/terminal/links.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -2429,7 +2429,7 @@
|
|||
"exportName": "ChannelSetupAdapter",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 56,
|
||||
"line": 60,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -3180,7 +3180,7 @@
|
|||
"exportName": "createChannelPluginBase",
|
||||
"kind": "function",
|
||||
"source": {
|
||||
"line": 437,
|
||||
"line": 447,
|
||||
"path": "src/plugin-sdk/core.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -3189,16 +3189,16 @@
|
|||
"exportName": "createChatChannelPlugin",
|
||||
"kind": "function",
|
||||
"source": {
|
||||
"line": 414,
|
||||
"line": 424,
|
||||
"path": "src/plugin-sdk/core.ts"
|
||||
}
|
||||
},
|
||||
{
|
||||
"declaration": "export function defineChannelPluginEntry<TPlugin>({ id, name, description, plugin, configSchema, setRuntime, registerFull, }: DefineChannelPluginEntryOptions<TPlugin>): DefinedPluginEntry;",
|
||||
"declaration": "export function defineChannelPluginEntry<TPlugin>({ id, name, description, plugin, configSchema, setRuntime, registerFull, }: DefineChannelPluginEntryOptions<TPlugin>): DefinedChannelPluginEntry<TPlugin>;",
|
||||
"exportName": "defineChannelPluginEntry",
|
||||
"kind": "function",
|
||||
"source": {
|
||||
"line": 246,
|
||||
"line": 251,
|
||||
"path": "src/plugin-sdk/core.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -3207,7 +3207,7 @@
|
|||
"exportName": "definePluginEntry",
|
||||
"kind": "function",
|
||||
"source": {
|
||||
"line": 91,
|
||||
"line": 129,
|
||||
"path": "src/plugin-sdk/plugin-entry.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -3216,7 +3216,7 @@
|
|||
"exportName": "defineSetupPluginEntry",
|
||||
"kind": "function",
|
||||
"source": {
|
||||
"line": 277,
|
||||
"line": 287,
|
||||
"path": "src/plugin-sdk/core.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -3531,7 +3531,7 @@
|
|||
"exportName": "GatewayRequestHandlerOptions",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 112,
|
||||
"line": 114,
|
||||
"path": "src/gateway/server-methods/types.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -4028,7 +4028,7 @@
|
|||
"exportName": "definePluginEntry",
|
||||
"kind": "function",
|
||||
"source": {
|
||||
"line": 91,
|
||||
"line": 129,
|
||||
"path": "src/plugin-sdk/plugin-entry.ts"
|
||||
}
|
||||
},
|
||||
|
|
@ -4448,24 +4448,6 @@
|
|||
"path": "src/plugins/provider-onboarding-config.ts"
|
||||
}
|
||||
},
|
||||
{
|
||||
"declaration": "export function applyCloudflareAiGatewayConfig(cfg: OpenClawConfig, params?: { accountId?: string | undefined; gatewayId?: string | undefined; } | undefined): OpenClawConfig;",
|
||||
"exportName": "applyCloudflareAiGatewayConfig",
|
||||
"kind": "function",
|
||||
"source": {
|
||||
"line": 85,
|
||||
"path": "extensions/cloudflare-ai-gateway/onboard.ts"
|
||||
}
|
||||
},
|
||||
{
|
||||
"declaration": "export function applyCloudflareAiGatewayProviderConfig(cfg: OpenClawConfig, params?: { accountId?: string | undefined; gatewayId?: string | undefined; } | undefined): OpenClawConfig;",
|
||||
"exportName": "applyCloudflareAiGatewayProviderConfig",
|
||||
"kind": "function",
|
||||
"source": {
|
||||
"line": 41,
|
||||
"path": "extensions/cloudflare-ai-gateway/onboard.ts"
|
||||
}
|
||||
},
|
||||
{
|
||||
"declaration": "export function applyOnboardAuthAgentModelsAndProviders(cfg: OpenClawConfig, params: { agentModels: Record<string, AgentModelEntryConfig>; providers: Record<string, ModelProviderConfig>; }): OpenClawConfig;",
|
||||
"exportName": "applyOnboardAuthAgentModelsAndProviders",
|
||||
|
|
@ -4529,24 +4511,6 @@
|
|||
"path": "src/plugins/provider-onboarding-config.ts"
|
||||
}
|
||||
},
|
||||
{
|
||||
"declaration": "export function applyVercelAiGatewayConfig(cfg: OpenClawConfig): OpenClawConfig;",
|
||||
"exportName": "applyVercelAiGatewayConfig",
|
||||
"kind": "function",
|
||||
"source": {
|
||||
"line": 27,
|
||||
"path": "extensions/vercel-ai-gateway/onboard.ts"
|
||||
}
|
||||
},
|
||||
{
|
||||
"declaration": "export function applyVercelAiGatewayProviderConfig(cfg: OpenClawConfig): OpenClawConfig;",
|
||||
"exportName": "applyVercelAiGatewayProviderConfig",
|
||||
"kind": "function",
|
||||
"source": {
|
||||
"line": 8,
|
||||
"path": "extensions/vercel-ai-gateway/onboard.ts"
|
||||
}
|
||||
},
|
||||
{
|
||||
"declaration": "export function createDefaultModelPresetAppliers<TArgs extends unknown[]>(params: { resolveParams: (cfg: OpenClawConfig, ...args: TArgs) => Omit<{ providerId: string; api: \"github-copilot\" | \"openai-completions\" | \"openai-responses\" | ... 4 more ... | \"ollama\"; ... 4 more ...; primaryModelRef?: string | undefined; }, \"primaryModelRef\"> | null | undefined; primaryModelRef: string; }): ProviderOnboardPresetAppliers<...>;",
|
||||
"exportName": "createDefaultModelPresetAppliers",
|
||||
|
|
@ -4592,24 +4556,6 @@
|
|||
"path": "src/plugins/provider-onboarding-config.ts"
|
||||
}
|
||||
},
|
||||
{
|
||||
"declaration": "export const CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF: \"cloudflare-ai-gateway/claude-sonnet-4-5\";",
|
||||
"exportName": "CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF",
|
||||
"kind": "const",
|
||||
"source": {
|
||||
"line": 5,
|
||||
"path": "src/agents/cloudflare-ai-gateway.ts"
|
||||
}
|
||||
},
|
||||
{
|
||||
"declaration": "export const VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF: \"vercel-ai-gateway/anthropic/claude-opus-4.6\";",
|
||||
"exportName": "VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF",
|
||||
"kind": "const",
|
||||
"source": {
|
||||
"line": 6,
|
||||
"path": "extensions/vercel-ai-gateway/onboard.ts"
|
||||
}
|
||||
},
|
||||
{
|
||||
"declaration": "export type AgentModelAliasEntry = AgentModelAliasEntry;",
|
||||
"exportName": "AgentModelAliasEntry",
|
||||
|
|
@ -5073,7 +5019,7 @@
|
|||
"exportName": "ChannelGatewayContext",
|
||||
"kind": "type",
|
||||
"source": {
|
||||
"line": 239,
|
||||
"line": 243,
|
||||
"path": "src/channels/plugins/types.adapters.ts"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10,16 +10,16 @@
|
|||
{"declaration":"export type ChannelCapabilities = ChannelCapabilities;","entrypoint":"index","exportName":"ChannelCapabilities","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":230,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
{"declaration":"export type ChannelConfigSchema = ChannelConfigSchema;","entrypoint":"index","exportName":"ChannelConfigSchema","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":68,"sourcePath":"src/channels/plugins/types.plugin.ts"}
|
||||
{"declaration":"export type ChannelConfigUiHint = ChannelConfigUiHint;","entrypoint":"index","exportName":"ChannelConfigUiHint","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":37,"sourcePath":"src/channels/plugins/types.plugin.ts"}
|
||||
{"declaration":"export type ChannelConfiguredBindingConversationRef = ChannelConfiguredBindingConversationRef;","entrypoint":"index","exportName":"ChannelConfiguredBindingConversationRef","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":554,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelConfiguredBindingMatch = ChannelConfiguredBindingMatch;","entrypoint":"index","exportName":"ChannelConfiguredBindingMatch","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":559,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelConfiguredBindingProvider = ChannelConfiguredBindingProvider;","entrypoint":"index","exportName":"ChannelConfiguredBindingProvider","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":563,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelGatewayContext = ChannelGatewayContext<ResolvedAccount>;","entrypoint":"index","exportName":"ChannelGatewayContext","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":239,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelConfiguredBindingConversationRef = ChannelConfiguredBindingConversationRef;","entrypoint":"index","exportName":"ChannelConfiguredBindingConversationRef","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":569,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelConfiguredBindingMatch = ChannelConfiguredBindingMatch;","entrypoint":"index","exportName":"ChannelConfiguredBindingMatch","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":574,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelConfiguredBindingProvider = ChannelConfiguredBindingProvider;","entrypoint":"index","exportName":"ChannelConfiguredBindingProvider","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":578,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelGatewayContext = ChannelGatewayContext<ResolvedAccount>;","entrypoint":"index","exportName":"ChannelGatewayContext","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":243,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelId = ChannelId;","entrypoint":"index","exportName":"ChannelId","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":13,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
{"declaration":"export type ChannelMessageActionAdapter = ChannelMessageActionAdapter;","entrypoint":"index","exportName":"ChannelMessageActionAdapter","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":516,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
{"declaration":"export type ChannelMessageActionContext = ChannelMessageActionContext;","entrypoint":"index","exportName":"ChannelMessageActionContext","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":482,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
{"declaration":"export type ChannelMessageActionName = \"send\" | \"broadcast\" | \"poll\" | \"poll-vote\" | \"react\" | \"reactions\" | \"read\" | \"edit\" | \"unsend\" | \"reply\" | \"sendWithEffect\" | \"renameGroup\" | \"setGroupIcon\" | \"addParticipant\" | \"removeParticipant\" | \"leaveGroup\" | \"sendAttachment\" | \"delete\" | \"pin\" | \"unpin\" | \"list-pins\" | \"permissions\" | \"thread-create\" | \"thread-list\" | \"thread-reply\" | \"search\" | \"sticker\" | \"sticker-search\" | \"member-info\" | \"role-info\" | \"emoji-list\" | \"emoji-upload\" | \"sticker-upload\" | \"role-add\" | \"role-remove\" | \"channel-info\" | \"channel-list\" | \"channel-create\" | \"channel-edit\" | \"channel-delete\" | \"channel-move\" | \"category-create\" | \"category-edit\" | \"category-delete\" | \"topic-create\" | \"topic-edit\" | \"voice-status\" | \"event-list\" | \"event-create\" | \"timeout\" | \"kick\" | \"ban\" | \"set-profile\" | \"set-presence\" | \"download-file\" | \"upload-file\";","entrypoint":"index","exportName":"ChannelMessageActionName","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":6,"sourcePath":"src/channels/plugins/types.ts"}
|
||||
{"declaration":"export type ChannelPlugin = ChannelPlugin<ResolvedAccount, Probe, Audit>;","entrypoint":"index","exportName":"ChannelPlugin","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":76,"sourcePath":"src/channels/plugins/types.plugin.ts"}
|
||||
{"declaration":"export type ChannelSetupAdapter = ChannelSetupAdapter;","entrypoint":"index","exportName":"ChannelSetupAdapter","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":56,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelSetupAdapter = ChannelSetupAdapter;","entrypoint":"index","exportName":"ChannelSetupAdapter","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":60,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelSetupInput = ChannelSetupInput;","entrypoint":"index","exportName":"ChannelSetupInput","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":63,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
{"declaration":"export type ChannelSetupWizard = ChannelSetupWizard;","entrypoint":"index","exportName":"ChannelSetupWizard","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":247,"sourcePath":"src/channels/plugins/setup-wizard.ts"}
|
||||
{"declaration":"export type ChannelSetupWizardAllowFromEntry = ChannelSetupWizardAllowFromEntry;","entrypoint":"index","exportName":"ChannelSetupWizardAllowFromEntry","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":154,"sourcePath":"src/channels/plugins/setup-wizard.ts"}
|
||||
|
|
@ -186,36 +186,36 @@
|
|||
{"declaration":"export type ChannelAgentPromptAdapter = ChannelAgentPromptAdapter;","entrypoint":"channel-runtime","exportName":"ChannelAgentPromptAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":463,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
{"declaration":"export type ChannelAgentTool = ChannelAgentTool;","entrypoint":"channel-runtime","exportName":"ChannelAgentTool","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":18,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
{"declaration":"export type ChannelAgentToolFactory = ChannelAgentToolFactory;","entrypoint":"channel-runtime","exportName":"ChannelAgentToolFactory","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":23,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
{"declaration":"export type ChannelAllowlistAdapter = ChannelAllowlistAdapter;","entrypoint":"channel-runtime","exportName":"ChannelAllowlistAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":498,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelAuthAdapter = ChannelAuthAdapter;","entrypoint":"channel-runtime","exportName":"ChannelAuthAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":363,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelAllowlistAdapter = ChannelAllowlistAdapter;","entrypoint":"channel-runtime","exportName":"ChannelAllowlistAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":513,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelAuthAdapter = ChannelAuthAdapter;","entrypoint":"channel-runtime","exportName":"ChannelAuthAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":367,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelCapabilities = ChannelCapabilities;","entrypoint":"channel-runtime","exportName":"ChannelCapabilities","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":230,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
{"declaration":"export type ChannelCapabilitiesDiagnostics = ChannelCapabilitiesDiagnostics;","entrypoint":"channel-runtime","exportName":"ChannelCapabilitiesDiagnostics","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":47,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelCapabilitiesDisplayLine = ChannelCapabilitiesDisplayLine;","entrypoint":"channel-runtime","exportName":"ChannelCapabilitiesDisplayLine","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":42,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelCapabilitiesDisplayTone = ChannelCapabilitiesDisplayTone;","entrypoint":"channel-runtime","exportName":"ChannelCapabilitiesDisplayTone","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":40,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelCommandAdapter = ChannelCommandAdapter;","entrypoint":"channel-runtime","exportName":"ChannelCommandAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":445,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelConfigAdapter = ChannelConfigAdapter<ResolvedAccount>;","entrypoint":"channel-runtime","exportName":"ChannelConfigAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":91,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelConfiguredBindingConversationRef = ChannelConfiguredBindingConversationRef;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingConversationRef","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":554,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelConfiguredBindingMatch = ChannelConfiguredBindingMatch;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingMatch","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":559,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelConfiguredBindingProvider = ChannelConfiguredBindingProvider;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingProvider","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":563,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelDirectoryAdapter = ChannelDirectoryAdapter;","entrypoint":"channel-runtime","exportName":"ChannelDirectoryAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":407,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelCapabilitiesDiagnostics = ChannelCapabilitiesDiagnostics;","entrypoint":"channel-runtime","exportName":"ChannelCapabilitiesDiagnostics","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":51,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelCapabilitiesDisplayLine = ChannelCapabilitiesDisplayLine;","entrypoint":"channel-runtime","exportName":"ChannelCapabilitiesDisplayLine","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":46,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelCapabilitiesDisplayTone = ChannelCapabilitiesDisplayTone;","entrypoint":"channel-runtime","exportName":"ChannelCapabilitiesDisplayTone","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":44,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelCommandAdapter = ChannelCommandAdapter;","entrypoint":"channel-runtime","exportName":"ChannelCommandAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":449,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelConfigAdapter = ChannelConfigAdapter<ResolvedAccount>;","entrypoint":"channel-runtime","exportName":"ChannelConfigAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":95,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelConfiguredBindingConversationRef = ChannelConfiguredBindingConversationRef;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingConversationRef","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":569,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelConfiguredBindingMatch = ChannelConfiguredBindingMatch;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingMatch","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":574,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelConfiguredBindingProvider = ChannelConfiguredBindingProvider;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingProvider","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":578,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelDirectoryAdapter = ChannelDirectoryAdapter;","entrypoint":"channel-runtime","exportName":"ChannelDirectoryAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":411,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelDirectoryEntry = ChannelDirectoryEntry;","entrypoint":"channel-runtime","exportName":"ChannelDirectoryEntry","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":469,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
{"declaration":"export type ChannelDirectoryEntryKind = ChannelDirectoryEntryKind;","entrypoint":"channel-runtime","exportName":"ChannelDirectoryEntryKind","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":467,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
{"declaration":"export type ChannelElevatedAdapter = ChannelElevatedAdapter;","entrypoint":"channel-runtime","exportName":"ChannelElevatedAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":438,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelExecApprovalAdapter = ChannelExecApprovalAdapter;","entrypoint":"channel-runtime","exportName":"ChannelExecApprovalAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":464,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelExecApprovalForwardTarget = ChannelExecApprovalForwardTarget;","entrypoint":"channel-runtime","exportName":"ChannelExecApprovalForwardTarget","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":32,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelExecApprovalInitiatingSurfaceState = ChannelExecApprovalInitiatingSurfaceState;","entrypoint":"channel-runtime","exportName":"ChannelExecApprovalInitiatingSurfaceState","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":27,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelGatewayAdapter = ChannelGatewayAdapter<ResolvedAccount>;","entrypoint":"channel-runtime","exportName":"ChannelGatewayAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":347,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelGatewayContext = ChannelGatewayContext<ResolvedAccount>;","entrypoint":"channel-runtime","exportName":"ChannelGatewayContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":239,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelGroupAdapter = ChannelGroupAdapter;","entrypoint":"channel-runtime","exportName":"ChannelGroupAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":122,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelElevatedAdapter = ChannelElevatedAdapter;","entrypoint":"channel-runtime","exportName":"ChannelElevatedAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":442,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelExecApprovalAdapter = ChannelExecApprovalAdapter;","entrypoint":"channel-runtime","exportName":"ChannelExecApprovalAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":468,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelExecApprovalForwardTarget = ChannelExecApprovalForwardTarget;","entrypoint":"channel-runtime","exportName":"ChannelExecApprovalForwardTarget","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":36,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelExecApprovalInitiatingSurfaceState = ChannelExecApprovalInitiatingSurfaceState;","entrypoint":"channel-runtime","exportName":"ChannelExecApprovalInitiatingSurfaceState","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":31,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelGatewayAdapter = ChannelGatewayAdapter<ResolvedAccount>;","entrypoint":"channel-runtime","exportName":"ChannelGatewayAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":351,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelGatewayContext = ChannelGatewayContext<ResolvedAccount>;","entrypoint":"channel-runtime","exportName":"ChannelGatewayContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":243,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelGroupAdapter = ChannelGroupAdapter;","entrypoint":"channel-runtime","exportName":"ChannelGroupAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":126,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelGroupContext = ChannelGroupContext;","entrypoint":"channel-runtime","exportName":"ChannelGroupContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":216,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
{"declaration":"export type ChannelHeartbeatAdapter = ChannelHeartbeatAdapter;","entrypoint":"channel-runtime","exportName":"ChannelHeartbeatAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":373,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelHeartbeatAdapter = ChannelHeartbeatAdapter;","entrypoint":"channel-runtime","exportName":"ChannelHeartbeatAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":377,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelHeartbeatDeps = ChannelHeartbeatDeps;","entrypoint":"channel-runtime","exportName":"ChannelHeartbeatDeps","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":116,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
{"declaration":"export type ChannelId = ChannelId;","entrypoint":"channel-runtime","exportName":"ChannelId","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":13,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
{"declaration":"export type ChannelLifecycleAdapter = ChannelLifecycleAdapter;","entrypoint":"channel-runtime","exportName":"ChannelLifecycleAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":450,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelLoginWithQrStartResult = ChannelLoginWithQrStartResult;","entrypoint":"channel-runtime","exportName":"ChannelLoginWithQrStartResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":318,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelLoginWithQrWaitResult = ChannelLoginWithQrWaitResult;","entrypoint":"channel-runtime","exportName":"ChannelLoginWithQrWaitResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":323,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelLogoutContext = ChannelLogoutContext<ResolvedAccount>;","entrypoint":"channel-runtime","exportName":"ChannelLogoutContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":328,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelLogoutResult = ChannelLogoutResult;","entrypoint":"channel-runtime","exportName":"ChannelLogoutResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":312,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelLifecycleAdapter = ChannelLifecycleAdapter;","entrypoint":"channel-runtime","exportName":"ChannelLifecycleAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":454,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelLoginWithQrStartResult = ChannelLoginWithQrStartResult;","entrypoint":"channel-runtime","exportName":"ChannelLoginWithQrStartResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":322,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelLoginWithQrWaitResult = ChannelLoginWithQrWaitResult;","entrypoint":"channel-runtime","exportName":"ChannelLoginWithQrWaitResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":327,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelLogoutContext = ChannelLogoutContext<ResolvedAccount>;","entrypoint":"channel-runtime","exportName":"ChannelLogoutContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":332,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelLogoutResult = ChannelLogoutResult;","entrypoint":"channel-runtime","exportName":"ChannelLogoutResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":316,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelLogSink = ChannelLogSink;","entrypoint":"channel-runtime","exportName":"ChannelLogSink","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":209,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
{"declaration":"export type ChannelMentionAdapter = ChannelMentionAdapter;","entrypoint":"channel-runtime","exportName":"ChannelMentionAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":260,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
{"declaration":"export type ChannelMessageActionAdapter = ChannelMessageActionAdapter;","entrypoint":"channel-runtime","exportName":"ChannelMessageActionAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":516,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
|
|
@ -227,22 +227,22 @@
|
|||
{"declaration":"export type ChannelMessageToolSchemaContribution = ChannelMessageToolSchemaContribution;","entrypoint":"channel-runtime","exportName":"ChannelMessageToolSchemaContribution","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":51,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
{"declaration":"export type ChannelMessagingAdapter = ChannelMessagingAdapter;","entrypoint":"channel-runtime","exportName":"ChannelMessagingAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":395,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
{"declaration":"export type ChannelMeta = ChannelMeta;","entrypoint":"channel-runtime","exportName":"ChannelMeta","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":122,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
{"declaration":"export type ChannelOutboundAdapter = ChannelOutboundAdapter;","entrypoint":"channel-runtime","exportName":"ChannelOutboundAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":155,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelOutboundContext = ChannelOutboundContext;","entrypoint":"channel-runtime","exportName":"ChannelOutboundContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":128,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelOutboundAdapter = ChannelOutboundAdapter;","entrypoint":"channel-runtime","exportName":"ChannelOutboundAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":159,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelOutboundContext = ChannelOutboundContext;","entrypoint":"channel-runtime","exportName":"ChannelOutboundContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":132,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelOutboundTargetMode = ChannelOutboundTargetMode;","entrypoint":"channel-runtime","exportName":"ChannelOutboundTargetMode","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":15,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
{"declaration":"export type ChannelPairingAdapter = ChannelPairingAdapter;","entrypoint":"channel-runtime","exportName":"ChannelPairingAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":336,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelPairingAdapter = ChannelPairingAdapter;","entrypoint":"channel-runtime","exportName":"ChannelPairingAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":340,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelPlugin = ChannelPlugin<ResolvedAccount, Probe, Audit>;","entrypoint":"channel-runtime","exportName":"ChannelPlugin","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":76,"sourcePath":"src/channels/plugins/types.plugin.ts"}
|
||||
{"declaration":"export type ChannelPollContext = ChannelPollContext;","entrypoint":"channel-runtime","exportName":"ChannelPollContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":547,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
{"declaration":"export type ChannelPollResult = ChannelPollResult;","entrypoint":"channel-runtime","exportName":"ChannelPollResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":538,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
{"declaration":"export type ChannelResolveKind = ChannelResolveKind;","entrypoint":"channel-runtime","exportName":"ChannelResolveKind","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":418,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelResolverAdapter = ChannelResolverAdapter;","entrypoint":"channel-runtime","exportName":"ChannelResolverAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":428,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelResolveResult = ChannelResolveResult;","entrypoint":"channel-runtime","exportName":"ChannelResolveResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":420,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelSecurityAdapter = ChannelSecurityAdapter<ResolvedAccount>;","entrypoint":"channel-runtime","exportName":"ChannelSecurityAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":576,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelResolveKind = ChannelResolveKind;","entrypoint":"channel-runtime","exportName":"ChannelResolveKind","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":422,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelResolverAdapter = ChannelResolverAdapter;","entrypoint":"channel-runtime","exportName":"ChannelResolverAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":432,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelResolveResult = ChannelResolveResult;","entrypoint":"channel-runtime","exportName":"ChannelResolveResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":424,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelSecurityAdapter = ChannelSecurityAdapter<ResolvedAccount>;","entrypoint":"channel-runtime","exportName":"ChannelSecurityAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":591,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelSecurityContext = ChannelSecurityContext<ResolvedAccount>;","entrypoint":"channel-runtime","exportName":"ChannelSecurityContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":254,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
{"declaration":"export type ChannelSecurityDmPolicy = ChannelSecurityDmPolicy;","entrypoint":"channel-runtime","exportName":"ChannelSecurityDmPolicy","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":245,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
{"declaration":"export type ChannelSetupAdapter = ChannelSetupAdapter;","entrypoint":"channel-runtime","exportName":"ChannelSetupAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":56,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelSetupAdapter = ChannelSetupAdapter;","entrypoint":"channel-runtime","exportName":"ChannelSetupAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":60,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelSetupInput = ChannelSetupInput;","entrypoint":"channel-runtime","exportName":"ChannelSetupInput","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":63,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
{"declaration":"export type ChannelStatusAdapter = ChannelStatusAdapter<ResolvedAccount, Probe, Audit>;","entrypoint":"channel-runtime","exportName":"ChannelStatusAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":185,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelStatusAdapter = ChannelStatusAdapter<ResolvedAccount, Probe, Audit>;","entrypoint":"channel-runtime","exportName":"ChannelStatusAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":189,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelStatusIssue = ChannelStatusIssue;","entrypoint":"channel-runtime","exportName":"ChannelStatusIssue","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":100,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
{"declaration":"export type ChannelStreamingAdapter = ChannelStreamingAdapter;","entrypoint":"channel-runtime","exportName":"ChannelStreamingAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":279,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
{"declaration":"export type ChannelStructuredComponents = ChannelStructuredComponents;","entrypoint":"channel-runtime","exportName":"ChannelStructuredComponents","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":288,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
|
|
@ -262,11 +262,11 @@
|
|||
{"declaration":"export function createOptionalChannelSetupSurface(params: OptionalChannelSetupParams): OptionalChannelSetupSurface;","entrypoint":"channel-setup","exportName":"createOptionalChannelSetupSurface","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"function","recordType":"export","sourceLine":40,"sourcePath":"src/plugin-sdk/channel-setup.ts"}
|
||||
{"declaration":"export function createOptionalChannelSetupWizard(params: OptionalChannelSetupParams): ChannelSetupWizard;","entrypoint":"channel-setup","exportName":"createOptionalChannelSetupWizard","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"function","recordType":"export","sourceLine":35,"sourcePath":"src/plugin-sdk/optional-channel-setup.ts"}
|
||||
{"declaration":"export function createTopLevelChannelDmPolicy(params: { label: string; channel: string; policyKey: string; allowFromKey: string; getCurrent: (cfg: OpenClawConfig) => DmPolicy; promptAllowFrom?: ((params: { cfg: OpenClawConfig; prompter: WizardPrompter; accountId?: string | undefined; }) => Promise<...>) | undefined; getAllowFrom?: ((cfg: OpenClawConfig) => (string | number)[] | undefined) | undefined; }): ChannelSetupDmPolicy;","entrypoint":"channel-setup","exportName":"createTopLevelChannelDmPolicy","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"function","recordType":"export","sourceLine":411,"sourcePath":"src/channels/plugins/setup-wizard-helpers.ts"}
|
||||
{"declaration":"export function formatDocsLink(path: string, label?: string | undefined, opts?: { fallback?: string | undefined; force?: boolean | undefined; } | undefined): string;","entrypoint":"channel-setup","exportName":"formatDocsLink","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"function","recordType":"export","sourceLine":5,"sourcePath":"src/terminal/links.ts"}
|
||||
{"declaration":"export function formatDocsLink(path: string, label?: string | undefined, opts?: { fallback?: string | undefined; force?: boolean | undefined; } | undefined): string;","entrypoint":"channel-setup","exportName":"formatDocsLink","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"function","recordType":"export","sourceLine":9,"sourcePath":"src/terminal/links.ts"}
|
||||
{"declaration":"export function setSetupChannelEnabled(cfg: OpenClawConfig, channel: string, enabled: boolean): OpenClawConfig;","entrypoint":"channel-setup","exportName":"setSetupChannelEnabled","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"function","recordType":"export","sourceLine":813,"sourcePath":"src/channels/plugins/setup-wizard-helpers.ts"}
|
||||
{"declaration":"export function splitSetupEntries(raw: string): string[];","entrypoint":"channel-setup","exportName":"splitSetupEntries","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"function","recordType":"export","sourceLine":80,"sourcePath":"src/channels/plugins/setup-wizard-helpers.ts"}
|
||||
{"declaration":"export const DEFAULT_ACCOUNT_ID: \"default\";","entrypoint":"channel-setup","exportName":"DEFAULT_ACCOUNT_ID","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"const","recordType":"export","sourceLine":3,"sourcePath":"src/routing/account-id.ts"}
|
||||
{"declaration":"export type ChannelSetupAdapter = ChannelSetupAdapter;","entrypoint":"channel-setup","exportName":"ChannelSetupAdapter","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"type","recordType":"export","sourceLine":56,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelSetupAdapter = ChannelSetupAdapter;","entrypoint":"channel-setup","exportName":"ChannelSetupAdapter","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"type","recordType":"export","sourceLine":60,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelSetupDmPolicy = ChannelSetupDmPolicy;","entrypoint":"channel-setup","exportName":"ChannelSetupDmPolicy","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"type","recordType":"export","sourceLine":93,"sourcePath":"src/channels/plugins/setup-wizard-types.ts"}
|
||||
{"declaration":"export type ChannelSetupInput = ChannelSetupInput;","entrypoint":"channel-setup","exportName":"ChannelSetupInput","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"type","recordType":"export","sourceLine":63,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
{"declaration":"export type ChannelSetupWizard = ChannelSetupWizard;","entrypoint":"channel-setup","exportName":"ChannelSetupWizard","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"type","recordType":"export","sourceLine":247,"sourcePath":"src/channels/plugins/setup-wizard.ts"}
|
||||
|
|
@ -349,11 +349,11 @@
|
|||
{"declaration":"export function channelTargetSchema(options?: { description?: string | undefined; } | undefined): TString;","entrypoint":"core","exportName":"channelTargetSchema","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":38,"sourcePath":"src/agents/schema/typebox.ts"}
|
||||
{"declaration":"export function channelTargetsSchema(options?: { description?: string | undefined; } | undefined): TArray<TString>;","entrypoint":"core","exportName":"channelTargetsSchema","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":44,"sourcePath":"src/agents/schema/typebox.ts"}
|
||||
{"declaration":"export function clearAccountEntryFields<TAccountEntry extends object>(params: { accounts?: Record<string, TAccountEntry> | undefined; accountId: string; fields: string[]; isValueSet?: ((value: unknown) => boolean) | undefined; markClearedOnFieldPresence?: boolean | undefined; }): { ...; };","entrypoint":"core","exportName":"clearAccountEntryFields","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":122,"sourcePath":"src/channels/plugins/config-helpers.ts"}
|
||||
{"declaration":"export function createChannelPluginBase<TResolvedAccount>(params: CreateChannelPluginBaseOptions<TResolvedAccount>): CreatedChannelPluginBase<TResolvedAccount>;","entrypoint":"core","exportName":"createChannelPluginBase","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":437,"sourcePath":"src/plugin-sdk/core.ts"}
|
||||
{"declaration":"export function createChatChannelPlugin<TResolvedAccount extends { accountId?: string | null; }, Probe = unknown, Audit = unknown>(params: { base: ChatChannelPluginBase<TResolvedAccount, Probe, Audit>; security?: ChannelSecurityAdapter<TResolvedAccount> | ChatChannelSecurityOptions<...> | undefined; pairing?: ChannelPairingAdapter | ... 1 more ... | undefined; threading?: ChannelThreadingAdapter | ... 1 more ... | undefined; outbound?: ChannelOutboundAdapter | ... 1 more ... | undefined; }): ChannelPlugin<...>;","entrypoint":"core","exportName":"createChatChannelPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":414,"sourcePath":"src/plugin-sdk/core.ts"}
|
||||
{"declaration":"export function defineChannelPluginEntry<TPlugin>({ id, name, description, plugin, configSchema, setRuntime, registerFull, }: DefineChannelPluginEntryOptions<TPlugin>): DefinedPluginEntry;","entrypoint":"core","exportName":"defineChannelPluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":246,"sourcePath":"src/plugin-sdk/core.ts"}
|
||||
{"declaration":"export function definePluginEntry({ id, name, description, kind, configSchema, register, }: DefinePluginEntryOptions): DefinedPluginEntry;","entrypoint":"core","exportName":"definePluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":91,"sourcePath":"src/plugin-sdk/plugin-entry.ts"}
|
||||
{"declaration":"export function defineSetupPluginEntry<TPlugin>(plugin: TPlugin): { plugin: TPlugin; };","entrypoint":"core","exportName":"defineSetupPluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":277,"sourcePath":"src/plugin-sdk/core.ts"}
|
||||
{"declaration":"export function createChannelPluginBase<TResolvedAccount>(params: CreateChannelPluginBaseOptions<TResolvedAccount>): CreatedChannelPluginBase<TResolvedAccount>;","entrypoint":"core","exportName":"createChannelPluginBase","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":447,"sourcePath":"src/plugin-sdk/core.ts"}
|
||||
{"declaration":"export function createChatChannelPlugin<TResolvedAccount extends { accountId?: string | null; }, Probe = unknown, Audit = unknown>(params: { base: ChatChannelPluginBase<TResolvedAccount, Probe, Audit>; security?: ChannelSecurityAdapter<TResolvedAccount> | ChatChannelSecurityOptions<...> | undefined; pairing?: ChannelPairingAdapter | ... 1 more ... | undefined; threading?: ChannelThreadingAdapter | ... 1 more ... | undefined; outbound?: ChannelOutboundAdapter | ... 1 more ... | undefined; }): ChannelPlugin<...>;","entrypoint":"core","exportName":"createChatChannelPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":424,"sourcePath":"src/plugin-sdk/core.ts"}
|
||||
{"declaration":"export function defineChannelPluginEntry<TPlugin>({ id, name, description, plugin, configSchema, setRuntime, registerFull, }: DefineChannelPluginEntryOptions<TPlugin>): DefinedChannelPluginEntry<TPlugin>;","entrypoint":"core","exportName":"defineChannelPluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":251,"sourcePath":"src/plugin-sdk/core.ts"}
|
||||
{"declaration":"export function definePluginEntry({ id, name, description, kind, configSchema, register, }: DefinePluginEntryOptions): DefinedPluginEntry;","entrypoint":"core","exportName":"definePluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":129,"sourcePath":"src/plugin-sdk/plugin-entry.ts"}
|
||||
{"declaration":"export function defineSetupPluginEntry<TPlugin>(plugin: TPlugin): { plugin: TPlugin; };","entrypoint":"core","exportName":"defineSetupPluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":287,"sourcePath":"src/plugin-sdk/core.ts"}
|
||||
{"declaration":"export function delegateCompactionToRuntime(params: { sessionId: string; sessionKey?: string | undefined; sessionFile: string; tokenBudget?: number | undefined; force?: boolean | undefined; currentTokenCount?: number | undefined; compactionTarget?: \"budget\" | ... 1 more ... | undefined; customInstructions?: string | undefined; runtimeContext?: ContextEngineRuntimeContext | undefined; }): Promise<...>;","entrypoint":"core","exportName":"delegateCompactionToRuntime","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":16,"sourcePath":"src/context-engine/delegate.ts"}
|
||||
{"declaration":"export function deleteAccountFromConfigSection(params: { cfg: OpenClawConfig; sectionKey: string; accountId: string; clearBaseFields?: string[] | undefined; }): OpenClawConfig;","entrypoint":"core","exportName":"deleteAccountFromConfigSection","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":60,"sourcePath":"src/channels/plugins/config-helpers.ts"}
|
||||
{"declaration":"export function emptyPluginConfigSchema(): OpenClawPluginConfigSchema;","entrypoint":"core","exportName":"emptyPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":108,"sourcePath":"src/plugins/config-schema.ts"}
|
||||
|
|
@ -388,7 +388,7 @@
|
|||
{"declaration":"export type ChannelOutboundSessionRouteParams = { cfg: OpenClawConfig; agentId: string; accountId?: string | null; target: string; resolvedTarget?: { to: string; kind: import(\"src/channels/plugins/types.core\").ChannelDirectoryEntryKind | \"channel\"; display?: string; source: \"normalized\" | \"directory\"; }; replyToId?: string | null; threadId?: string | number | null;};","entrypoint":"core","exportName":"ChannelOutboundSessionRouteParams","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":138,"sourcePath":"src/plugin-sdk/core.ts"}
|
||||
{"declaration":"export type ChannelPlugin = ChannelPlugin<ResolvedAccount, Probe, Audit>;","entrypoint":"core","exportName":"ChannelPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":76,"sourcePath":"src/channels/plugins/types.plugin.ts"}
|
||||
{"declaration":"export type GatewayBindUrlResult = GatewayBindUrlResult;","entrypoint":"core","exportName":"GatewayBindUrlResult","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1,"sourcePath":"src/shared/gateway-bind-url.ts"}
|
||||
{"declaration":"export type GatewayRequestHandlerOptions = GatewayRequestHandlerOptions;","entrypoint":"core","exportName":"GatewayRequestHandlerOptions","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":112,"sourcePath":"src/gateway/server-methods/types.ts"}
|
||||
{"declaration":"export type GatewayRequestHandlerOptions = GatewayRequestHandlerOptions;","entrypoint":"core","exportName":"GatewayRequestHandlerOptions","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":114,"sourcePath":"src/gateway/server-methods/types.ts"}
|
||||
{"declaration":"export type MediaUnderstandingProviderPlugin = MediaUnderstandingProvider;","entrypoint":"core","exportName":"MediaUnderstandingProviderPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1105,"sourcePath":"src/plugins/types.ts"}
|
||||
{"declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"core","exportName":"OpenClawConfig","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":32,"sourcePath":"src/config/types.openclaw.ts"}
|
||||
{"declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"core","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1496,"sourcePath":"src/plugins/types.ts"}
|
||||
|
|
@ -443,7 +443,7 @@
|
|||
{"declaration":"export type UsageWindow = UsageWindow;","entrypoint":"core","exportName":"UsageWindow","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1,"sourcePath":"src/infra/provider-usage.types.ts"}
|
||||
{"declaration":"export class KeyedAsyncQueue","entrypoint":"core","exportName":"KeyedAsyncQueue","importSpecifier":"openclaw/plugin-sdk/core","kind":"class","recordType":"export","sourceLine":34,"sourcePath":"src/plugin-sdk/keyed-async-queue.ts"}
|
||||
{"category":"core","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry","recordType":"module","sourceLine":1,"sourcePath":"src/plugin-sdk/plugin-entry.ts"}
|
||||
{"declaration":"export function definePluginEntry({ id, name, description, kind, configSchema, register, }: DefinePluginEntryOptions): DefinedPluginEntry;","entrypoint":"plugin-entry","exportName":"definePluginEntry","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"function","recordType":"export","sourceLine":91,"sourcePath":"src/plugin-sdk/plugin-entry.ts"}
|
||||
{"declaration":"export function definePluginEntry({ id, name, description, kind, configSchema, register, }: DefinePluginEntryOptions): DefinedPluginEntry;","entrypoint":"plugin-entry","exportName":"definePluginEntry","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"function","recordType":"export","sourceLine":129,"sourcePath":"src/plugin-sdk/plugin-entry.ts"}
|
||||
{"declaration":"export function emptyPluginConfigSchema(): OpenClawPluginConfigSchema;","entrypoint":"plugin-entry","exportName":"emptyPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"function","recordType":"export","sourceLine":108,"sourcePath":"src/plugins/config-schema.ts"}
|
||||
{"declaration":"export type AnyAgentTool = AnyAgentTool;","entrypoint":"plugin-entry","exportName":"AnyAgentTool","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":9,"sourcePath":"src/agents/tools/common.ts"}
|
||||
{"declaration":"export type MediaUnderstandingProviderPlugin = MediaUnderstandingProvider;","entrypoint":"plugin-entry","exportName":"MediaUnderstandingProviderPlugin","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1105,"sourcePath":"src/plugins/types.ts"}
|
||||
|
|
@ -490,8 +490,6 @@
|
|||
{"declaration":"export type SpeechProviderPlugin = SpeechProviderPlugin;","entrypoint":"plugin-entry","exportName":"SpeechProviderPlugin","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1080,"sourcePath":"src/plugins/types.ts"}
|
||||
{"category":"provider","entrypoint":"provider-onboard","importSpecifier":"openclaw/plugin-sdk/provider-onboard","recordType":"module","sourceLine":1,"sourcePath":"src/plugin-sdk/provider-onboard.ts"}
|
||||
{"declaration":"export function applyAgentDefaultModelPrimary(cfg: OpenClawConfig, primary: string): OpenClawConfig;","entrypoint":"provider-onboard","exportName":"applyAgentDefaultModelPrimary","importSpecifier":"openclaw/plugin-sdk/provider-onboard","kind":"function","recordType":"export","sourceLine":76,"sourcePath":"src/plugins/provider-onboarding-config.ts"}
|
||||
{"declaration":"export function applyCloudflareAiGatewayConfig(cfg: OpenClawConfig, params?: { accountId?: string | undefined; gatewayId?: string | undefined; } | undefined): OpenClawConfig;","entrypoint":"provider-onboard","exportName":"applyCloudflareAiGatewayConfig","importSpecifier":"openclaw/plugin-sdk/provider-onboard","kind":"function","recordType":"export","sourceLine":85,"sourcePath":"extensions/cloudflare-ai-gateway/onboard.ts"}
|
||||
{"declaration":"export function applyCloudflareAiGatewayProviderConfig(cfg: OpenClawConfig, params?: { accountId?: string | undefined; gatewayId?: string | undefined; } | undefined): OpenClawConfig;","entrypoint":"provider-onboard","exportName":"applyCloudflareAiGatewayProviderConfig","importSpecifier":"openclaw/plugin-sdk/provider-onboard","kind":"function","recordType":"export","sourceLine":41,"sourcePath":"extensions/cloudflare-ai-gateway/onboard.ts"}
|
||||
{"declaration":"export function applyOnboardAuthAgentModelsAndProviders(cfg: OpenClawConfig, params: { agentModels: Record<string, AgentModelEntryConfig>; providers: Record<string, ModelProviderConfig>; }): OpenClawConfig;","entrypoint":"provider-onboard","exportName":"applyOnboardAuthAgentModelsAndProviders","importSpecifier":"openclaw/plugin-sdk/provider-onboard","kind":"function","recordType":"export","sourceLine":53,"sourcePath":"src/plugins/provider-onboarding-config.ts"}
|
||||
{"declaration":"export function applyProviderConfigWithDefaultModel(cfg: OpenClawConfig, params: { agentModels: Record<string, AgentModelEntryConfig>; providerId: string; api: \"github-copilot\" | \"openai-completions\" | ... 5 more ... | \"ollama\"; baseUrl: string; defaultModel: ModelDefinitionConfig; defaultModelId?: string | undefined; }): OpenClawConfig;","entrypoint":"provider-onboard","exportName":"applyProviderConfigWithDefaultModel","importSpecifier":"openclaw/plugin-sdk/provider-onboard","kind":"function","recordType":"export","sourceLine":131,"sourcePath":"src/plugins/provider-onboarding-config.ts"}
|
||||
{"declaration":"export function applyProviderConfigWithDefaultModelPreset(cfg: OpenClawConfig, params: { providerId: string; api: \"github-copilot\" | \"openai-completions\" | \"openai-responses\" | \"openai-codex-responses\" | \"anthropic-messages\" | \"google-generative-ai\" | \"bedrock-converse-stream\" | \"ollama\"; ... 4 more ...; primaryModelRef?: string | undefined; }): OpenClawConfig;","entrypoint":"provider-onboard","exportName":"applyProviderConfigWithDefaultModelPreset","importSpecifier":"openclaw/plugin-sdk/provider-onboard","kind":"function","recordType":"export","sourceLine":152,"sourcePath":"src/plugins/provider-onboarding-config.ts"}
|
||||
|
|
@ -499,15 +497,11 @@
|
|||
{"declaration":"export function applyProviderConfigWithDefaultModelsPreset(cfg: OpenClawConfig, params: { providerId: string; api: \"github-copilot\" | \"openai-completions\" | \"openai-responses\" | \"openai-codex-responses\" | \"anthropic-messages\" | \"google-generative-ai\" | \"bedrock-converse-stream\" | \"ollama\"; ... 4 more ...; primaryModelRef?: string | undefined; }): OpenClawConfig;","entrypoint":"provider-onboard","exportName":"applyProviderConfigWithDefaultModelsPreset","importSpecifier":"openclaw/plugin-sdk/provider-onboard","kind":"function","recordType":"export","sourceLine":230,"sourcePath":"src/plugins/provider-onboarding-config.ts"}
|
||||
{"declaration":"export function applyProviderConfigWithModelCatalog(cfg: OpenClawConfig, params: { agentModels: Record<string, AgentModelEntryConfig>; providerId: string; api: \"github-copilot\" | \"openai-completions\" | ... 5 more ... | \"ollama\"; baseUrl: string; catalogModels: ModelDefinitionConfig[]; }): OpenClawConfig;","entrypoint":"provider-onboard","exportName":"applyProviderConfigWithModelCatalog","importSpecifier":"openclaw/plugin-sdk/provider-onboard","kind":"function","recordType":"export","sourceLine":272,"sourcePath":"src/plugins/provider-onboarding-config.ts"}
|
||||
{"declaration":"export function applyProviderConfigWithModelCatalogPreset(cfg: OpenClawConfig, params: { providerId: string; api: \"github-copilot\" | \"openai-completions\" | \"openai-responses\" | \"openai-codex-responses\" | \"anthropic-messages\" | \"google-generative-ai\" | \"bedrock-converse-stream\" | \"ollama\"; baseUrl: string; catalogModels: ModelDefinitionConfig[]; aliases?: readonly AgentModelAliasEntry[] | undefined; primaryModelRef?: string | undefined; }): OpenClawConfig;","entrypoint":"provider-onboard","exportName":"applyProviderConfigWithModelCatalogPreset","importSpecifier":"openclaw/plugin-sdk/provider-onboard","kind":"function","recordType":"export","sourceLine":304,"sourcePath":"src/plugins/provider-onboarding-config.ts"}
|
||||
{"declaration":"export function applyVercelAiGatewayConfig(cfg: OpenClawConfig): OpenClawConfig;","entrypoint":"provider-onboard","exportName":"applyVercelAiGatewayConfig","importSpecifier":"openclaw/plugin-sdk/provider-onboard","kind":"function","recordType":"export","sourceLine":27,"sourcePath":"extensions/vercel-ai-gateway/onboard.ts"}
|
||||
{"declaration":"export function applyVercelAiGatewayProviderConfig(cfg: OpenClawConfig): OpenClawConfig;","entrypoint":"provider-onboard","exportName":"applyVercelAiGatewayProviderConfig","importSpecifier":"openclaw/plugin-sdk/provider-onboard","kind":"function","recordType":"export","sourceLine":8,"sourcePath":"extensions/vercel-ai-gateway/onboard.ts"}
|
||||
{"declaration":"export function createDefaultModelPresetAppliers<TArgs extends unknown[]>(params: { resolveParams: (cfg: OpenClawConfig, ...args: TArgs) => Omit<{ providerId: string; api: \"github-copilot\" | \"openai-completions\" | \"openai-responses\" | ... 4 more ... | \"ollama\"; ... 4 more ...; primaryModelRef?: string | undefined; }, \"primaryModelRef\"> | null | undefined; primaryModelRef: string; }): ProviderOnboardPresetAppliers<...>;","entrypoint":"provider-onboard","exportName":"createDefaultModelPresetAppliers","importSpecifier":"openclaw/plugin-sdk/provider-onboard","kind":"function","recordType":"export","sourceLine":213,"sourcePath":"src/plugins/provider-onboarding-config.ts"}
|
||||
{"declaration":"export function createDefaultModelsPresetAppliers<TArgs extends unknown[]>(params: { resolveParams: (cfg: OpenClawConfig, ...args: TArgs) => Omit<{ providerId: string; api: \"github-copilot\" | \"openai-completions\" | \"openai-responses\" | ... 4 more ... | \"ollama\"; ... 4 more ...; primaryModelRef?: string | undefined; }, \"primaryModelRef\"> | null | undefined; primaryModelRef: string; }): ProviderOnboardPresetAppliers<...>;","entrypoint":"provider-onboard","exportName":"createDefaultModelsPresetAppliers","importSpecifier":"openclaw/plugin-sdk/provider-onboard","kind":"function","recordType":"export","sourceLine":255,"sourcePath":"src/plugins/provider-onboarding-config.ts"}
|
||||
{"declaration":"export function createModelCatalogPresetAppliers<TArgs extends unknown[]>(params: { resolveParams: (cfg: OpenClawConfig, ...args: TArgs) => Omit<{ providerId: string; api: \"github-copilot\" | \"openai-completions\" | \"openai-responses\" | ... 4 more ... | \"ollama\"; baseUrl: string; catalogModels: ModelDefinitionConfig[]; aliases?: readonly AgentModelAliasEntry[] | undefined; primaryModelRef?: string | undefined; }, \"primaryModelRef\"> | null | undefined; primaryModelRef: string; }): ProviderOnboardPresetAppliers<...>;","entrypoint":"provider-onboard","exportName":"createModelCatalogPresetAppliers","importSpecifier":"openclaw/plugin-sdk/provider-onboard","kind":"function","recordType":"export","sourceLine":327,"sourcePath":"src/plugins/provider-onboarding-config.ts"}
|
||||
{"declaration":"export function ensureModelAllowlistEntry(params: { cfg: OpenClawConfig; modelRef: string; defaultProvider?: string | undefined; }): OpenClawConfig;","entrypoint":"provider-onboard","exportName":"ensureModelAllowlistEntry","importSpecifier":"openclaw/plugin-sdk/provider-onboard","kind":"function","recordType":"export","sourceLine":5,"sourcePath":"src/plugins/provider-model-allowlist.ts"}
|
||||
{"declaration":"export function withAgentModelAliases(existing: Record<string, AgentModelEntryConfig> | undefined, aliases: readonly AgentModelAliasEntry[]): Record<string, AgentModelEntryConfig>;","entrypoint":"provider-onboard","exportName":"withAgentModelAliases","importSpecifier":"openclaw/plugin-sdk/provider-onboard","kind":"function","recordType":"export","sourceLine":38,"sourcePath":"src/plugins/provider-onboarding-config.ts"}
|
||||
{"declaration":"export const CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF: \"cloudflare-ai-gateway/claude-sonnet-4-5\";","entrypoint":"provider-onboard","exportName":"CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF","importSpecifier":"openclaw/plugin-sdk/provider-onboard","kind":"const","recordType":"export","sourceLine":5,"sourcePath":"src/agents/cloudflare-ai-gateway.ts"}
|
||||
{"declaration":"export const VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF: \"vercel-ai-gateway/anthropic/claude-opus-4.6\";","entrypoint":"provider-onboard","exportName":"VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF","importSpecifier":"openclaw/plugin-sdk/provider-onboard","kind":"const","recordType":"export","sourceLine":6,"sourcePath":"extensions/vercel-ai-gateway/onboard.ts"}
|
||||
{"declaration":"export type AgentModelAliasEntry = AgentModelAliasEntry;","entrypoint":"provider-onboard","exportName":"AgentModelAliasEntry","importSpecifier":"openclaw/plugin-sdk/provider-onboard","kind":"type","recordType":"export","sourceLine":21,"sourcePath":"src/plugins/provider-onboarding-config.ts"}
|
||||
{"declaration":"export type ModelApi = \"github-copilot\" | \"openai-completions\" | \"openai-responses\" | \"openai-codex-responses\" | \"anthropic-messages\" | \"google-generative-ai\" | \"bedrock-converse-stream\" | \"ollama\";","entrypoint":"provider-onboard","exportName":"ModelApi","importSpecifier":"openclaw/plugin-sdk/provider-onboard","kind":"type","recordType":"export","sourceLine":15,"sourcePath":"src/config/types.models.ts"}
|
||||
{"declaration":"export type ModelDefinitionConfig = ModelDefinitionConfig;","entrypoint":"provider-onboard","exportName":"ModelDefinitionConfig","importSpecifier":"openclaw/plugin-sdk/provider-onboard","kind":"type","recordType":"export","sourceLine":47,"sourcePath":"src/config/types.models.ts"}
|
||||
|
|
@ -558,7 +552,7 @@
|
|||
{"declaration":"export function removeAckReactionAfterReply(params: { removeAfterReply: boolean; ackReactionPromise: Promise<boolean> | null; ackReactionValue: string | null; remove: () => Promise<void>; onError?: ((err: unknown) => void) | undefined; }): void;","entrypoint":"testing","exportName":"removeAckReactionAfterReply","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":81,"sourcePath":"src/channels/ack-reactions.ts"}
|
||||
{"declaration":"export function shouldAckReaction(params: AckReactionGateParams): boolean;","entrypoint":"testing","exportName":"shouldAckReaction","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":16,"sourcePath":"src/channels/ack-reactions.ts"}
|
||||
{"declaration":"export type ChannelAccountSnapshot = ChannelAccountSnapshot;","entrypoint":"testing","exportName":"ChannelAccountSnapshot","importSpecifier":"openclaw/plugin-sdk/testing","kind":"type","recordType":"export","sourceLine":144,"sourcePath":"src/channels/plugins/types.core.ts"}
|
||||
{"declaration":"export type ChannelGatewayContext = ChannelGatewayContext<ResolvedAccount>;","entrypoint":"testing","exportName":"ChannelGatewayContext","importSpecifier":"openclaw/plugin-sdk/testing","kind":"type","recordType":"export","sourceLine":239,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type ChannelGatewayContext = ChannelGatewayContext<ResolvedAccount>;","entrypoint":"testing","exportName":"ChannelGatewayContext","importSpecifier":"openclaw/plugin-sdk/testing","kind":"type","recordType":"export","sourceLine":243,"sourcePath":"src/channels/plugins/types.adapters.ts"}
|
||||
{"declaration":"export type MockFn = MockFn<T>;","entrypoint":"testing","exportName":"MockFn","importSpecifier":"openclaw/plugin-sdk/testing","kind":"type","recordType":"export","sourceLine":5,"sourcePath":"src/test-utils/vitest-mock-fn.ts"}
|
||||
{"declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"testing","exportName":"OpenClawConfig","importSpecifier":"openclaw/plugin-sdk/testing","kind":"type","recordType":"export","sourceLine":32,"sourcePath":"src/config/types.openclaw.ts"}
|
||||
{"declaration":"export type PluginRuntime = PluginRuntime;","entrypoint":"testing","exportName":"PluginRuntime","importSpecifier":"openclaw/plugin-sdk/testing","kind":"type","recordType":"export","sourceLine":54,"sourcePath":"src/plugins/runtime/types.ts"}
|
||||
|
|
|
|||
|
|
@ -459,6 +459,44 @@ These hooks are not event-stream listeners; they let plugins synchronously adjus
|
|||
|
||||
### Plugin Hook Events
|
||||
|
||||
#### before_tool_call
|
||||
|
||||
Runs before each tool call. Plugins can modify parameters, block the call, or request user approval.
|
||||
|
||||
Return fields:
|
||||
|
||||
- **`params`**: Override tool parameters (merged with original params)
|
||||
- **`block`**: Set to `true` to block the tool call
|
||||
- **`blockReason`**: Reason shown to the agent when blocked
|
||||
- **`requireApproval`**: Pause execution and wait for user approval via channels
|
||||
|
||||
The `requireApproval` field triggers native platform approval (Telegram buttons, Discord components, `/approve` command) instead of relying on the agent to cooperate:
|
||||
|
||||
```typescript
|
||||
{
|
||||
requireApproval: {
|
||||
title: "Sensitive operation",
|
||||
description: "This tool call modifies production data",
|
||||
severity: "warning", // "info" | "warning" | "critical"
|
||||
timeoutMs: 120000, // default: 120s
|
||||
timeoutBehavior: "deny", // "allow" | "deny" (default)
|
||||
onResolution: async (decision) => {
|
||||
// Called after the user resolves: "allow-once", "allow-always", "deny", "timeout", or "cancelled"
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `onResolution` callback is invoked with the final decision string after the approval resolves, times out, or is cancelled. It runs in-process within the plugin (not sent to the gateway). Use it to persist decisions, update caches, or perform cleanup.
|
||||
|
||||
The `pluginId` field is stamped automatically by the hook runner from the plugin registration. When multiple plugins return `requireApproval`, the first one (highest priority) wins.
|
||||
|
||||
`block` takes precedence over `requireApproval`: if the merged hook result has both `block: true` and a `requireApproval` field, the tool call is blocked immediately without triggering the approval flow. This ensures a higher-priority plugin's block cannot be overridden by a lower-priority plugin's approval request.
|
||||
|
||||
If the gateway is unavailable or does not support plugin approvals, the tool call falls back to a soft block using the `description` as the block reason.
|
||||
|
||||
#### Compaction lifecycle
|
||||
|
||||
Compaction lifecycle hooks exposed through the plugin hook runner:
|
||||
|
||||
- **`before_compaction`**: Runs before compaction with count/token metadata
|
||||
|
|
|
|||
|
|
@ -361,6 +361,35 @@ Reply in chat:
|
|||
/approve <id> deny
|
||||
```
|
||||
|
||||
The `/approve` command handles both exec approvals and plugin approvals. If the ID does not match a pending exec approval, it automatically checks plugin approvals.
|
||||
|
||||
### Plugin approval forwarding
|
||||
|
||||
Plugin approval forwarding uses the same delivery pipeline as exec approvals but has its own
|
||||
independent config under `approvals.plugin`. Enabling or disabling one does not affect the other.
|
||||
|
||||
```json5
|
||||
{
|
||||
approvals: {
|
||||
plugin: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
agentFilter: ["main"],
|
||||
targets: [
|
||||
{ channel: "slack", to: "U12345678" },
|
||||
{ channel: "telegram", to: "123456789" },
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The config shape is identical to `approvals.exec`: `enabled`, `mode`, `agentFilter`,
|
||||
`sessionFilter`, and `targets` work the same way.
|
||||
|
||||
Channels that support interactive exec approval buttons (such as Telegram) also render buttons for
|
||||
plugin approvals. Channels without adapter support fall back to plain text with `/approve` instructions.
|
||||
|
||||
### Built-in chat approval clients
|
||||
|
||||
Discord and Telegram can also act as explicit exec approval clients with channel-specific config.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
PluginApprovalRequest,
|
||||
PluginApprovalResolved,
|
||||
} from "../../../src/infra/plugin-approvals.js";
|
||||
import type { PluginRuntime } from "../../../src/plugins/runtime/types.js";
|
||||
import { createStartAccountContext } from "../../../test/helpers/extensions/start-account-context.js";
|
||||
import type { ResolvedDiscordAccount } from "./accounts.js";
|
||||
|
|
@ -45,6 +49,38 @@ function createCfg(): OpenClawConfig {
|
|||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
function createPluginApprovalRequest(
|
||||
overrides?: Partial<PluginApprovalRequest["request"]>,
|
||||
): PluginApprovalRequest {
|
||||
return {
|
||||
id: "plugin:approval-1",
|
||||
request: {
|
||||
title: "Sensitive plugin action",
|
||||
description: "The plugin asked to perform a sensitive action.",
|
||||
severity: "warning",
|
||||
pluginId: "plugin-test",
|
||||
toolName: "plugin.tool",
|
||||
agentId: "agent-1",
|
||||
sessionKey: "agent:agent-1:discord:channel:123456789",
|
||||
...overrides,
|
||||
},
|
||||
createdAtMs: 1_000,
|
||||
expiresAtMs: 61_000,
|
||||
};
|
||||
}
|
||||
|
||||
function createPluginApprovalResolved(
|
||||
request?: PluginApprovalRequest["request"],
|
||||
): PluginApprovalResolved {
|
||||
return {
|
||||
id: "plugin:approval-1",
|
||||
decision: "allow-once",
|
||||
resolvedBy: "discord:123",
|
||||
ts: 2_000,
|
||||
request,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAccount(cfg: OpenClawConfig): ResolvedDiscordAccount {
|
||||
return discordPlugin.config.resolveAccount(cfg, "default") as ResolvedDiscordAccount;
|
||||
}
|
||||
|
|
@ -112,6 +148,115 @@ describe("discordPlugin outbound", () => {
|
|||
expect(result).toMatchObject({ channel: "discord", messageId: "m1" });
|
||||
});
|
||||
|
||||
it("builds interactive plugin approval pending payloads for Discord forwarding", () => {
|
||||
const cfg = createCfg();
|
||||
cfg.channels!.discord!.execApprovals = {
|
||||
enabled: true,
|
||||
approvers: ["123"],
|
||||
};
|
||||
const payload = discordPlugin.execApprovals?.buildPluginPendingPayload?.({
|
||||
cfg,
|
||||
request: createPluginApprovalRequest(),
|
||||
target: { channel: "discord", to: "user:123" },
|
||||
nowMs: 2_000,
|
||||
});
|
||||
|
||||
expect(payload?.text).toContain("Plugin approval required");
|
||||
const discordData = (payload?.channelData as { discord?: { components?: unknown } } | undefined)
|
||||
?.discord;
|
||||
expect(discordData?.components).toBeDefined();
|
||||
const componentsJson = JSON.stringify(discordData?.components ?? {});
|
||||
expect(componentsJson).toContain("Plugin Approval Required");
|
||||
expect(componentsJson).toContain("execapproval:id=plugin%3Aapproval-1;action=allow-once");
|
||||
const execApproval = (payload?.channelData as { execApproval?: { approvalId?: string } })
|
||||
?.execApproval;
|
||||
expect(execApproval?.approvalId).toBe("plugin:approval-1");
|
||||
});
|
||||
|
||||
it("neutralizes plugin approval mentions in forwarded text and components", () => {
|
||||
const cfg = createCfg();
|
||||
cfg.channels!.discord!.execApprovals = {
|
||||
enabled: true,
|
||||
approvers: ["123"],
|
||||
};
|
||||
const payload = discordPlugin.execApprovals?.buildPluginPendingPayload?.({
|
||||
cfg,
|
||||
request: createPluginApprovalRequest({
|
||||
title: "Heads up @everyone <@123> <@&456>",
|
||||
description: "route @here and <#789>",
|
||||
}),
|
||||
target: { channel: "discord", to: "user:123" },
|
||||
nowMs: 2_000,
|
||||
});
|
||||
|
||||
const text = payload?.text ?? "";
|
||||
const componentsJson = JSON.stringify(
|
||||
((payload?.channelData as { discord?: { components?: unknown } } | undefined)?.discord
|
||||
?.components ?? {}) as object,
|
||||
);
|
||||
|
||||
expect(text).toContain("@\u200beveryone");
|
||||
expect(text).toContain("@\u200bhere");
|
||||
expect(text).toContain("<@\u200b123>");
|
||||
expect(text).toContain("<@\u200b&456>");
|
||||
expect(text).toContain("<#\u200b789>");
|
||||
expect(text).not.toContain("@everyone");
|
||||
expect(text).not.toContain("@here");
|
||||
expect(componentsJson).not.toContain("@everyone");
|
||||
expect(componentsJson).not.toContain("@here");
|
||||
expect(componentsJson).not.toContain("<@123>");
|
||||
expect(componentsJson).not.toContain("<@&456>");
|
||||
expect(componentsJson).not.toContain("<#789>");
|
||||
});
|
||||
|
||||
it("falls back to non-interactive plugin approval pending payload when Discord exec approvals are disabled", () => {
|
||||
const payload = discordPlugin.execApprovals?.buildPluginPendingPayload?.({
|
||||
cfg: createCfg(),
|
||||
request: createPluginApprovalRequest(),
|
||||
target: { channel: "discord", to: "user:123" },
|
||||
nowMs: 2_000,
|
||||
});
|
||||
|
||||
expect(payload?.text).toContain("Plugin approval required");
|
||||
const channelData = payload?.channelData as
|
||||
| {
|
||||
execApproval?: { approvalId?: string; approvalSlug?: string };
|
||||
discord?: { components?: unknown };
|
||||
}
|
||||
| undefined;
|
||||
expect(channelData?.execApproval?.approvalId).toBe("plugin:approval-1");
|
||||
expect(channelData?.execApproval?.approvalSlug).toBe("plugin:a");
|
||||
expect(channelData?.discord?.components).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds rich plugin approval resolved payloads when request snapshot is available", () => {
|
||||
const payload = discordPlugin.execApprovals?.buildPluginResolvedPayload?.({
|
||||
cfg: createCfg(),
|
||||
resolved: createPluginApprovalResolved(createPluginApprovalRequest().request),
|
||||
target: { channel: "discord", to: "user:123" },
|
||||
});
|
||||
|
||||
expect(payload?.text).toContain("Plugin approval allowed once");
|
||||
const discordData = (payload?.channelData as { discord?: { components?: unknown } } | undefined)
|
||||
?.discord;
|
||||
expect(discordData?.components).toBeDefined();
|
||||
const componentsJson = JSON.stringify(discordData?.components ?? {});
|
||||
expect(componentsJson).toContain("Plugin Approval: Allowed (once)");
|
||||
});
|
||||
|
||||
it("falls back to plain text plugin resolved payload when request snapshot is missing", () => {
|
||||
const payload = discordPlugin.execApprovals?.buildPluginResolvedPayload?.({
|
||||
cfg: createCfg(),
|
||||
resolved: createPluginApprovalResolved(undefined),
|
||||
target: { channel: "discord", to: "user:123" },
|
||||
});
|
||||
|
||||
expect(payload?.text).toContain("Plugin approval allowed once");
|
||||
const discordData = (payload?.channelData as { discord?: { components?: unknown } } | undefined)
|
||||
?.discord;
|
||||
expect(discordData?.components).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses direct Discord probe helpers for status probes", async () => {
|
||||
const runtimeProbeDiscord = vi.fn(async () => {
|
||||
throw new Error("runtime Discord probe should not be used");
|
||||
|
|
|
|||
|
|
@ -14,8 +14,12 @@ import {
|
|||
createRuntimeDirectoryLiveAdapter,
|
||||
} from "openclaw/plugin-sdk/directory-runtime";
|
||||
import {
|
||||
buildPluginApprovalRequestMessage,
|
||||
buildPluginApprovalResolvedMessage,
|
||||
createRuntimeOutboundDelegates,
|
||||
resolveOutboundSendDep,
|
||||
type PluginApprovalRequest,
|
||||
type PluginApprovalResolved,
|
||||
} from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { normalizeMessageChannel } from "openclaw/plugin-sdk/routing";
|
||||
import {
|
||||
|
|
@ -28,6 +32,7 @@ import {
|
|||
type ResolvedDiscordAccount,
|
||||
} from "./accounts.js";
|
||||
import { auditDiscordChannelPermissions, collectDiscordAuditChannelIds } from "./audit.js";
|
||||
import type { DiscordComponentMessageSpec } from "./components.js";
|
||||
import {
|
||||
listDiscordDirectoryGroupsFromConfig,
|
||||
listDiscordDirectoryPeersFromConfig,
|
||||
|
|
@ -88,6 +93,7 @@ async function loadDiscordProbeRuntime() {
|
|||
|
||||
const meta = getChatChannelMeta("discord");
|
||||
const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const;
|
||||
const DISCORD_EXEC_APPROVAL_KEY = "execapproval";
|
||||
|
||||
const resolveDiscordDmPolicy = createScopedDmSecurityResolver<ResolvedDiscordAccount>({
|
||||
channelKey: "discord",
|
||||
|
|
@ -116,6 +122,147 @@ function formatDiscordIntents(intents?: {
|
|||
].join(" ");
|
||||
}
|
||||
|
||||
function encodeCustomIdValue(value: string): string {
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
|
||||
function buildDiscordExecApprovalCustomId(
|
||||
approvalId: string,
|
||||
action: "allow-once" | "allow-always" | "deny",
|
||||
): string {
|
||||
return [
|
||||
`${DISCORD_EXEC_APPROVAL_KEY}:id=${encodeCustomIdValue(approvalId)}`,
|
||||
`action=${action}`,
|
||||
].join(";");
|
||||
}
|
||||
|
||||
function formatDiscordApprovalPreview(value: string, maxChars: number): string {
|
||||
const trimmed = value
|
||||
.replace(/@everyone/gi, "@\u200beveryone")
|
||||
.replace(/@here/gi, "@\u200bhere")
|
||||
.replace(/<@/g, "<@\u200b")
|
||||
.replace(/<#/g, "<#\u200b")
|
||||
.trim();
|
||||
const raw = trimmed.length > maxChars ? `${trimmed.slice(0, maxChars)}...` : trimmed;
|
||||
return raw.replace(/`/g, "\u200b`");
|
||||
}
|
||||
|
||||
function buildDiscordPluginPendingComponentSpec(params: {
|
||||
request: PluginApprovalRequest;
|
||||
}): DiscordComponentMessageSpec {
|
||||
const request = params.request.request;
|
||||
const severity = request.severity ?? "warning";
|
||||
const severityLabel =
|
||||
severity === "critical" ? "Critical" : severity === "info" ? "Info" : "Warning";
|
||||
const accentColor =
|
||||
severity === "critical" ? "#ED4245" : severity === "info" ? "#5865F2" : "#FAA61A";
|
||||
const expiresAtSeconds = Math.max(0, Math.floor(params.request.expiresAtMs / 1000));
|
||||
const metadataLines: string[] = [`- Severity: ${severityLabel}`];
|
||||
if (request.toolName) {
|
||||
metadataLines.push(`- Tool: ${request.toolName}`);
|
||||
}
|
||||
if (request.pluginId) {
|
||||
metadataLines.push(`- Plugin: ${request.pluginId}`);
|
||||
}
|
||||
if (request.agentId) {
|
||||
metadataLines.push(`- Agent: ${request.agentId}`);
|
||||
}
|
||||
return {
|
||||
container: { accentColor },
|
||||
blocks: [
|
||||
{ type: "text", text: "## Plugin Approval Required" },
|
||||
{ type: "text", text: "A plugin action needs your approval." },
|
||||
{ type: "separator", divider: true, spacing: "small" },
|
||||
{
|
||||
type: "text",
|
||||
text: `### Title\n\`\`\`\n${formatDiscordApprovalPreview(request.title, 500)}\n\`\`\``,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: `### Description\n${formatDiscordApprovalPreview(request.description, 1000)}`,
|
||||
},
|
||||
{ type: "text", text: metadataLines.join("\n") },
|
||||
{
|
||||
type: "actions",
|
||||
buttons: [
|
||||
{
|
||||
label: "Allow once",
|
||||
style: "success",
|
||||
internalCustomId: buildDiscordExecApprovalCustomId(params.request.id, "allow-once"),
|
||||
},
|
||||
{
|
||||
label: "Always allow",
|
||||
style: "primary",
|
||||
internalCustomId: buildDiscordExecApprovalCustomId(params.request.id, "allow-always"),
|
||||
},
|
||||
{
|
||||
label: "Deny",
|
||||
style: "danger",
|
||||
internalCustomId: buildDiscordExecApprovalCustomId(params.request.id, "deny"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "separator", divider: false, spacing: "small" },
|
||||
{ type: "text", text: `-# Expires <t:${expiresAtSeconds}:R> · ID: ${params.request.id}` },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildDiscordPluginResolvedComponentSpec(params: {
|
||||
resolved: PluginApprovalResolved;
|
||||
}): DiscordComponentMessageSpec | undefined {
|
||||
const request = params.resolved.request;
|
||||
if (!request) {
|
||||
return undefined;
|
||||
}
|
||||
const decisionLabel =
|
||||
params.resolved.decision === "allow-once"
|
||||
? "Allowed (once)"
|
||||
: params.resolved.decision === "allow-always"
|
||||
? "Allowed (always)"
|
||||
: "Denied";
|
||||
const accentColor =
|
||||
params.resolved.decision === "deny"
|
||||
? "#ED4245"
|
||||
: params.resolved.decision === "allow-always"
|
||||
? "#5865F2"
|
||||
: "#57F287";
|
||||
const metadataLines: string[] = [];
|
||||
if (request.toolName) {
|
||||
metadataLines.push(`- Tool: ${request.toolName}`);
|
||||
}
|
||||
if (request.pluginId) {
|
||||
metadataLines.push(`- Plugin: ${request.pluginId}`);
|
||||
}
|
||||
if (request.agentId) {
|
||||
metadataLines.push(`- Agent: ${request.agentId}`);
|
||||
}
|
||||
return {
|
||||
container: { accentColor },
|
||||
blocks: [
|
||||
{ type: "text", text: `## Plugin Approval: ${decisionLabel}` },
|
||||
{
|
||||
type: "text",
|
||||
text: params.resolved.resolvedBy ? `Resolved by ${params.resolved.resolvedBy}` : "Resolved",
|
||||
},
|
||||
{ type: "separator", divider: true, spacing: "small" },
|
||||
{
|
||||
type: "text",
|
||||
text: `### Title\n\`\`\`\n${formatDiscordApprovalPreview(request.title, 500)}\n\`\`\``,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: `### Description\n${formatDiscordApprovalPreview(request.description, 1000)}`,
|
||||
},
|
||||
...(metadataLines.length > 0
|
||||
? [{ type: "text" as const, text: metadataLines.join("\n") }]
|
||||
: []),
|
||||
{ type: "separator", divider: false, spacing: "small" },
|
||||
{ type: "text", text: `-# ID: ${params.resolved.id}` },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const discordMessageActions: ChannelMessageActionAdapter = {
|
||||
describeMessageTool: (ctx) =>
|
||||
getDiscordRuntime().channel.discord.messageActions?.describeMessageTool?.(ctx) ?? null,
|
||||
|
|
@ -293,6 +440,55 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
|
|||
shouldSuppressForwardingFallback: ({ cfg, target }) =>
|
||||
(normalizeMessageChannel(target.channel) ?? target.channel) === "discord" &&
|
||||
isDiscordExecApprovalClientEnabled({ cfg, accountId: target.accountId }),
|
||||
buildPluginPendingPayload: ({ cfg, request, target, nowMs }) => {
|
||||
const text = formatDiscordApprovalPreview(
|
||||
buildPluginApprovalRequestMessage(request, nowMs),
|
||||
10_000,
|
||||
);
|
||||
const execApproval = {
|
||||
approvalId: request.id,
|
||||
approvalSlug: request.id.slice(0, 8),
|
||||
allowedDecisions: ["allow-once", "allow-always", "deny"] as const,
|
||||
};
|
||||
const normalizedChannel = normalizeMessageChannel(target.channel) ?? target.channel;
|
||||
const interactiveEnabled =
|
||||
normalizedChannel === "discord" &&
|
||||
isDiscordExecApprovalClientEnabled({ cfg, accountId: target.accountId });
|
||||
if (!interactiveEnabled) {
|
||||
return {
|
||||
text,
|
||||
channelData: {
|
||||
execApproval,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
text,
|
||||
channelData: {
|
||||
execApproval,
|
||||
discord: {
|
||||
components: buildDiscordPluginPendingComponentSpec({ request }),
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
buildPluginResolvedPayload: ({ resolved }) => {
|
||||
const componentSpec = buildDiscordPluginResolvedComponentSpec({ resolved });
|
||||
const text = formatDiscordApprovalPreview(
|
||||
buildPluginApprovalResolvedMessage(resolved),
|
||||
10_000,
|
||||
);
|
||||
return componentSpec
|
||||
? {
|
||||
text,
|
||||
channelData: {
|
||||
discord: {
|
||||
components: componentSpec,
|
||||
},
|
||||
},
|
||||
}
|
||||
: { text };
|
||||
},
|
||||
},
|
||||
directory: createChannelDirectoryAdapter({
|
||||
listPeers: async (params) => listDiscordDirectoryPeersFromConfig(params),
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ export type DiscordComponentButtonSpec = {
|
|||
style?: DiscordComponentButtonStyle;
|
||||
url?: string;
|
||||
callbackData?: string;
|
||||
/** Internal use only: bypass dynamic component ids with a fixed custom id. */
|
||||
internalCustomId?: string;
|
||||
emoji?: {
|
||||
name: string;
|
||||
id?: string;
|
||||
|
|
@ -719,10 +721,16 @@ function createButtonComponent(params: {
|
|||
return { component: new DynamicLinkButton() };
|
||||
}
|
||||
const componentId = params.componentId ?? createShortId("btn_");
|
||||
const customId = buildDiscordComponentCustomId({
|
||||
componentId,
|
||||
modalId: params.modalId,
|
||||
});
|
||||
const internalCustomId =
|
||||
typeof params.spec.internalCustomId === "string" && params.spec.internalCustomId.trim()
|
||||
? params.spec.internalCustomId.trim()
|
||||
: undefined;
|
||||
const customId =
|
||||
internalCustomId ??
|
||||
buildDiscordComponentCustomId({
|
||||
componentId,
|
||||
modalId: params.modalId,
|
||||
});
|
||||
class DynamicButton extends Button {
|
||||
label = params.spec.label;
|
||||
customId = customId;
|
||||
|
|
@ -730,6 +738,11 @@ function createButtonComponent(params: {
|
|||
emoji = params.spec.emoji;
|
||||
disabled = params.spec.disabled ?? false;
|
||||
}
|
||||
if (internalCustomId) {
|
||||
return {
|
||||
component: new DynamicButton(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
component: new DynamicButton(),
|
||||
entry: {
|
||||
|
|
|
|||
|
|
@ -46,7 +46,9 @@ const mockRestPatch = vi.hoisted(() => vi.fn());
|
|||
const mockRestDelete = vi.hoisted(() => vi.fn());
|
||||
const gatewayClientStarts = vi.hoisted(() => vi.fn());
|
||||
const gatewayClientStops = vi.hoisted(() => vi.fn());
|
||||
const gatewayClientRequests = vi.hoisted(() => vi.fn(async () => ({ ok: true })));
|
||||
const gatewayClientRequests = vi.hoisted(() =>
|
||||
vi.fn(async (_method?: string, _params?: unknown) => ({ ok: true })),
|
||||
);
|
||||
const gatewayClientParams = vi.hoisted(() => [] as Array<Record<string, unknown>>);
|
||||
const mockGatewayClientCtor = vi.hoisted(() => vi.fn());
|
||||
const mockResolveGatewayConnectionAuth = vi.hoisted(() => vi.fn());
|
||||
|
|
@ -85,8 +87,8 @@ vi.mock("openclaw/plugin-sdk/gateway-runtime", async (importOriginal) => {
|
|||
stop() {
|
||||
gatewayClientStops();
|
||||
}
|
||||
async request() {
|
||||
return gatewayClientRequests();
|
||||
async request(method: string, params?: unknown) {
|
||||
return gatewayClientRequests(method, params);
|
||||
}
|
||||
}
|
||||
return {
|
||||
|
|
@ -237,6 +239,7 @@ type DiscordExecApprovalHandlerInstance = InstanceType<
|
|||
>;
|
||||
|
||||
type ExecApprovalRequest = import("./exec-approvals.js").ExecApprovalRequest;
|
||||
type PluginApprovalRequest = import("./exec-approvals.js").PluginApprovalRequest;
|
||||
type ExecApprovalButtonContext = import("./exec-approvals.js").ExecApprovalButtonContext;
|
||||
|
||||
function createTestingDeps() {
|
||||
|
|
@ -372,8 +375,8 @@ type ExecApprovalHandlerInternals = {
|
|||
string,
|
||||
{ discordMessageId: string; discordChannelId: string; timeoutId: NodeJS.Timeout }
|
||||
>;
|
||||
requestCache: Map<string, ExecApprovalRequest>;
|
||||
handleApprovalRequested: (request: ExecApprovalRequest) => Promise<void>;
|
||||
requestCache: Map<string, unknown>;
|
||||
handleApprovalRequested: (request: ExecApprovalRequest | PluginApprovalRequest) => Promise<void>;
|
||||
handleApprovalTimeout: (approvalId: string, source?: "channel" | "dm") => Promise<void>;
|
||||
};
|
||||
|
||||
|
|
@ -409,6 +412,39 @@ function createRequest(
|
|||
};
|
||||
}
|
||||
|
||||
function createPluginRequest(
|
||||
overrides: Partial<PluginApprovalRequest["request"]> = {},
|
||||
): PluginApprovalRequest {
|
||||
return {
|
||||
id: "plugin:test-id",
|
||||
request: {
|
||||
title: "Sensitive plugin action",
|
||||
description: "The plugin wants to run a sensitive tool action.",
|
||||
severity: "warning",
|
||||
toolName: "plugin.tool",
|
||||
pluginId: "plugin-test",
|
||||
agentId: "test-agent",
|
||||
sessionKey: "agent:test-agent:discord:channel:999888777",
|
||||
...overrides,
|
||||
},
|
||||
createdAtMs: Date.now(),
|
||||
expiresAtMs: Date.now() + 60000,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockButtonInteraction(userId: string) {
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const acknowledge = vi.fn().mockResolvedValue(undefined);
|
||||
const followUp = vi.fn().mockResolvedValue(undefined);
|
||||
const interaction = {
|
||||
userId,
|
||||
reply,
|
||||
acknowledge,
|
||||
followUp,
|
||||
} as unknown as ButtonInteraction;
|
||||
return { interaction, reply, acknowledge, followUp };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockRestPost.mockReset();
|
||||
mockRestPatch.mockReset();
|
||||
|
|
@ -690,6 +726,104 @@ describe("DiscordExecApprovalHandler.shouldHandle", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("DiscordExecApprovalHandler plugin approvals", () => {
|
||||
beforeEach(() => {
|
||||
mockRestPost.mockClear().mockResolvedValue({ id: "mock-message", channel_id: "mock-channel" });
|
||||
mockRestPatch.mockClear().mockResolvedValue({});
|
||||
mockRestDelete.mockClear().mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("delivers plugin approval requests with interactive approval buttons", async () => {
|
||||
const handler = createHandler({ enabled: true, approvers: ["123"] });
|
||||
const internals = getHandlerInternals(handler);
|
||||
mockSuccessfulDmDelivery({ throwOnUnexpectedRoute: true });
|
||||
|
||||
await internals.handleApprovalRequested(createPluginRequest());
|
||||
|
||||
const dmCall = mockRestPost.mock.calls.find(
|
||||
(call) => call[0] === Routes.channelMessages("dm-1"),
|
||||
) as [string, { body?: unknown }] | undefined;
|
||||
expect(dmCall).toBeDefined();
|
||||
expect(dmCall?.[1]?.body).toBeDefined();
|
||||
const bodyJson = JSON.stringify(dmCall?.[1]?.body ?? {});
|
||||
expect(bodyJson).toContain("Plugin Approval Required");
|
||||
expect(bodyJson).toContain("plugin:test-id");
|
||||
expect(bodyJson).toContain("execapproval:id=plugin%3Atest-id;action=allow-once");
|
||||
|
||||
clearPendingTimeouts(handler);
|
||||
});
|
||||
|
||||
it("handles plugin approvals end-to-end via gateway event, button resolve, and card update", async () => {
|
||||
const handler = createHandler({ enabled: true, approvers: ["123"] });
|
||||
mockSuccessfulDmDelivery({
|
||||
noteChannelId: "999888777",
|
||||
expectedNoteText: "I sent the allowed approvers DMs",
|
||||
throwOnUnexpectedRoute: true,
|
||||
});
|
||||
|
||||
await handler.start();
|
||||
try {
|
||||
const onEvent = gatewayClientParams[0]?.onEvent as
|
||||
| ((evt: { event: string; payload: unknown }) => void)
|
||||
| undefined;
|
||||
expect(typeof onEvent).toBe("function");
|
||||
|
||||
const request = createPluginRequest();
|
||||
onEvent?.({
|
||||
event: "plugin.approval.requested",
|
||||
payload: request,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockRestPost).toHaveBeenCalledWith(
|
||||
Routes.channelMessages("dm-1"),
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
components: expect.any(Array),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const button = new ExecApprovalButton({ handler });
|
||||
const { interaction, acknowledge } = createMockButtonInteraction("123");
|
||||
await button.run(interaction, { id: request.id, action: "allow-once" });
|
||||
|
||||
expect(acknowledge).toHaveBeenCalledTimes(1);
|
||||
expect(gatewayClientRequests).toHaveBeenCalledWith("plugin.approval.resolve", {
|
||||
id: request.id,
|
||||
decision: "allow-once",
|
||||
});
|
||||
|
||||
onEvent?.({
|
||||
event: "plugin.approval.resolved",
|
||||
payload: {
|
||||
id: request.id,
|
||||
decision: "allow-once",
|
||||
resolvedBy: "discord:123",
|
||||
ts: Date.now(),
|
||||
request: request.request,
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockRestPatch).toHaveBeenCalledWith(
|
||||
Routes.channelMessage("dm-1", "msg-1"),
|
||||
expect.objectContaining({ body: expect.any(Object) }),
|
||||
);
|
||||
});
|
||||
const patchCall = mockRestPatch.mock.calls.find(
|
||||
(call) => call[0] === Routes.channelMessage("dm-1", "msg-1"),
|
||||
) as [string, { body?: unknown }] | undefined;
|
||||
const patchBody = JSON.stringify(patchCall?.[1]?.body ?? {});
|
||||
expect(patchBody).toContain("Plugin Approval: Allowed (once)");
|
||||
} finally {
|
||||
clearPendingTimeouts(handler);
|
||||
await handler.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DiscordExecApprovalHandler.getApprovers ──────────────────────────────────
|
||||
|
||||
describe("DiscordExecApprovalHandler.getApprovers", () => {
|
||||
|
|
@ -719,6 +853,40 @@ describe("DiscordExecApprovalHandler.getApprovers", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("DiscordExecApprovalHandler.resolveApproval", () => {
|
||||
it("routes non-prefixed approval IDs to exec.approval.resolve", async () => {
|
||||
const handler = createHandler({ enabled: true, approvers: ["123"] });
|
||||
await handler.start();
|
||||
|
||||
try {
|
||||
const ok = await handler.resolveApproval("exec-123", "allow-once");
|
||||
expect(ok).toBe(true);
|
||||
expect(gatewayClientRequests).toHaveBeenCalledWith("exec.approval.resolve", {
|
||||
id: "exec-123",
|
||||
decision: "allow-once",
|
||||
});
|
||||
} finally {
|
||||
await handler.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("routes plugin-prefixed approval IDs to plugin.approval.resolve", async () => {
|
||||
const handler = createHandler({ enabled: true, approvers: ["123"] });
|
||||
await handler.start();
|
||||
|
||||
try {
|
||||
const ok = await handler.resolveApproval("plugin:abc-123", "deny");
|
||||
expect(ok).toBe(true);
|
||||
expect(gatewayClientRequests).toHaveBeenCalledWith("plugin.approval.resolve", {
|
||||
id: "plugin:abc-123",
|
||||
decision: "deny",
|
||||
});
|
||||
} finally {
|
||||
await handler.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ExecApprovalButton authorization ─────────────────────────────────────────
|
||||
|
||||
describe("ExecApprovalButton", () => {
|
||||
|
|
@ -756,7 +924,7 @@ describe("ExecApprovalButton", () => {
|
|||
await button.run(interaction, data);
|
||||
|
||||
expect(reply).toHaveBeenCalledWith({
|
||||
content: "⛔ You are not authorized to approve exec requests.",
|
||||
content: "⛔ You are not authorized to approve requests.",
|
||||
ephemeral: true,
|
||||
});
|
||||
expect(acknowledge).not.toHaveBeenCalled();
|
||||
|
|
@ -992,8 +1160,8 @@ describe("DiscordExecApprovalHandler timeout cleanup", () => {
|
|||
const requestA = { ...createRequest(), id: "abc" };
|
||||
const requestB = { ...createRequest(), id: "abc2" };
|
||||
|
||||
internals.requestCache.set("abc", requestA);
|
||||
internals.requestCache.set("abc2", requestB);
|
||||
internals.requestCache.set("abc", { kind: "exec", request: requestA });
|
||||
internals.requestCache.set("abc2", { kind: "exec", request: requestB });
|
||||
|
||||
const timeoutIdA = setTimeout(() => {}, 0);
|
||||
const timeoutIdB = setTimeout(() => {}, 0);
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import type {
|
|||
ExecApprovalDecision,
|
||||
ExecApprovalRequest,
|
||||
ExecApprovalResolved,
|
||||
PluginApprovalRequest,
|
||||
PluginApprovalResolved,
|
||||
} from "openclaw/plugin-sdk/infra-runtime";
|
||||
import {
|
||||
normalizeAccountId,
|
||||
|
|
@ -34,7 +36,12 @@ import * as sendShared from "../send.shared.js";
|
|||
import { DiscordUiContainer } from "../ui.js";
|
||||
|
||||
const EXEC_APPROVAL_KEY = "execapproval";
|
||||
export type { ExecApprovalRequest, ExecApprovalResolved };
|
||||
export type {
|
||||
ExecApprovalRequest,
|
||||
ExecApprovalResolved,
|
||||
PluginApprovalRequest,
|
||||
PluginApprovalResolved,
|
||||
};
|
||||
|
||||
/** Extract Discord channel ID from a session key like "agent:main:discord:channel:123456789" */
|
||||
export function extractDiscordChannelId(sessionKey?: string | null): string | null {
|
||||
|
|
@ -58,6 +65,12 @@ type PendingApproval = {
|
|||
timeoutId: NodeJS.Timeout;
|
||||
};
|
||||
|
||||
type ApprovalKind = "exec" | "plugin";
|
||||
|
||||
type CachedApprovalRequest =
|
||||
| { kind: "exec"; request: ExecApprovalRequest }
|
||||
| { kind: "plugin"; request: PluginApprovalRequest };
|
||||
|
||||
function encodeCustomIdValue(value: string): string {
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
|
|
@ -102,6 +115,16 @@ export function parseExecApprovalData(
|
|||
};
|
||||
}
|
||||
|
||||
function resolveApprovalKindFromId(approvalId: string): ApprovalKind {
|
||||
return approvalId.startsWith("plugin:") ? "plugin" : "exec";
|
||||
}
|
||||
|
||||
function isPluginApprovalRequest(
|
||||
request: ExecApprovalRequest | PluginApprovalRequest,
|
||||
): request is PluginApprovalRequest {
|
||||
return resolveApprovalKindFromId(request.id) === "plugin";
|
||||
}
|
||||
|
||||
type ExecApprovalContainerParams = {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
|
|
@ -192,11 +215,11 @@ class ExecApprovalActionRow extends Row<Button> {
|
|||
}
|
||||
}
|
||||
|
||||
function resolveExecApprovalAccountId(params: {
|
||||
function resolveAccountIdFromSessionKey(params: {
|
||||
cfg: OpenClawConfig;
|
||||
request: ExecApprovalRequest;
|
||||
sessionKey?: string | null;
|
||||
}): string | null {
|
||||
const sessionKey = params.request.request.sessionKey?.trim();
|
||||
const sessionKey = params.sessionKey?.trim();
|
||||
if (!sessionKey) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -216,6 +239,51 @@ function resolveExecApprovalAccountId(params: {
|
|||
}
|
||||
}
|
||||
|
||||
function resolveExecApprovalAccountId(params: {
|
||||
cfg: OpenClawConfig;
|
||||
request: ExecApprovalRequest;
|
||||
}): string | null {
|
||||
return resolveAccountIdFromSessionKey({
|
||||
cfg: params.cfg,
|
||||
sessionKey: params.request.request.sessionKey,
|
||||
});
|
||||
}
|
||||
|
||||
function resolvePluginApprovalAccountId(params: {
|
||||
cfg: OpenClawConfig;
|
||||
request: PluginApprovalRequest;
|
||||
}): string | null {
|
||||
const fromSession = resolveAccountIdFromSessionKey({
|
||||
cfg: params.cfg,
|
||||
sessionKey: params.request.request.sessionKey,
|
||||
});
|
||||
if (fromSession) {
|
||||
return fromSession;
|
||||
}
|
||||
return params.request.request.turnSourceAccountId?.trim() || null;
|
||||
}
|
||||
|
||||
function resolveApprovalAccountId(params: {
|
||||
cfg: OpenClawConfig;
|
||||
request: ExecApprovalRequest | PluginApprovalRequest;
|
||||
}): string | null {
|
||||
return isPluginApprovalRequest(params.request)
|
||||
? resolvePluginApprovalAccountId({ cfg: params.cfg, request: params.request })
|
||||
: resolveExecApprovalAccountId({ cfg: params.cfg, request: params.request });
|
||||
}
|
||||
|
||||
function resolveApprovalAgentId(
|
||||
request: ExecApprovalRequest | PluginApprovalRequest,
|
||||
): string | null {
|
||||
return request.request.agentId?.trim() || null;
|
||||
}
|
||||
|
||||
function resolveApprovalSessionKey(
|
||||
request: ExecApprovalRequest | PluginApprovalRequest,
|
||||
): string | null {
|
||||
return request.request.sessionKey?.trim() || null;
|
||||
}
|
||||
|
||||
function buildExecApprovalMetadataLines(request: ExecApprovalRequest): string[] {
|
||||
const lines: string[] = [];
|
||||
if (request.request.cwd) {
|
||||
|
|
@ -233,6 +301,24 @@ function buildExecApprovalMetadataLines(request: ExecApprovalRequest): string[]
|
|||
return lines;
|
||||
}
|
||||
|
||||
function buildPluginApprovalMetadataLines(request: PluginApprovalRequest): string[] {
|
||||
const lines: string[] = [];
|
||||
const severity = request.request.severity ?? "warning";
|
||||
lines.push(
|
||||
`- Severity: ${severity === "critical" ? "Critical" : severity === "info" ? "Info" : "Warning"}`,
|
||||
);
|
||||
if (request.request.toolName) {
|
||||
lines.push(`- Tool: ${request.request.toolName}`);
|
||||
}
|
||||
if (request.request.pluginId) {
|
||||
lines.push(`- Plugin: ${request.request.pluginId}`);
|
||||
}
|
||||
if (request.request.agentId) {
|
||||
lines.push(`- Agent: ${request.request.agentId}`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildExecApprovalPayload(container: DiscordUiContainer): MessagePayloadObject {
|
||||
const components: TopLevelComponents[] = [container];
|
||||
return { components };
|
||||
|
|
@ -294,7 +380,31 @@ function createExecApprovalRequestContainer(params: {
|
|||
});
|
||||
}
|
||||
|
||||
function createResolvedContainer(params: {
|
||||
function createPluginApprovalRequestContainer(params: {
|
||||
request: PluginApprovalRequest;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
actionRow?: Row<Button>;
|
||||
}): ExecApprovalContainer {
|
||||
const expiresAtSeconds = Math.max(0, Math.floor(params.request.expiresAtMs / 1000));
|
||||
const severity = params.request.request.severity ?? "warning";
|
||||
const accentColor =
|
||||
severity === "critical" ? "#ED4245" : severity === "info" ? "#5865F2" : "#FAA61A";
|
||||
return new ExecApprovalContainer({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
title: "Plugin Approval Required",
|
||||
description: "A plugin action needs your approval.",
|
||||
commandPreview: formatCommandPreview(params.request.request.title, 700),
|
||||
commandSecondaryPreview: formatOptionalCommandPreview(params.request.request.description, 1000),
|
||||
metadataLines: buildPluginApprovalMetadataLines(params.request),
|
||||
actionRow: params.actionRow,
|
||||
footer: `Expires <t:${expiresAtSeconds}:R> · ID: ${params.request.id}`,
|
||||
accentColor,
|
||||
});
|
||||
}
|
||||
|
||||
function createExecResolvedContainer(params: {
|
||||
request: ExecApprovalRequest;
|
||||
decision: ExecApprovalDecision;
|
||||
resolvedBy?: string | null;
|
||||
|
|
@ -333,7 +443,41 @@ function createResolvedContainer(params: {
|
|||
});
|
||||
}
|
||||
|
||||
function createExpiredContainer(params: {
|
||||
function createPluginResolvedContainer(params: {
|
||||
request: PluginApprovalRequest;
|
||||
decision: ExecApprovalDecision;
|
||||
resolvedBy?: string | null;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
}): ExecApprovalContainer {
|
||||
const decisionLabel =
|
||||
params.decision === "allow-once"
|
||||
? "Allowed (once)"
|
||||
: params.decision === "allow-always"
|
||||
? "Allowed (always)"
|
||||
: "Denied";
|
||||
|
||||
const accentColor =
|
||||
params.decision === "deny"
|
||||
? "#ED4245"
|
||||
: params.decision === "allow-always"
|
||||
? "#5865F2"
|
||||
: "#57F287";
|
||||
|
||||
return new ExecApprovalContainer({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
title: `Plugin Approval: ${decisionLabel}`,
|
||||
description: params.resolvedBy ? `Resolved by ${params.resolvedBy}` : "Resolved",
|
||||
commandPreview: formatCommandPreview(params.request.request.title, 700),
|
||||
commandSecondaryPreview: formatOptionalCommandPreview(params.request.request.description, 1000),
|
||||
metadataLines: buildPluginApprovalMetadataLines(params.request),
|
||||
footer: `ID: ${params.request.id}`,
|
||||
accentColor,
|
||||
});
|
||||
}
|
||||
|
||||
function createExecExpiredContainer(params: {
|
||||
request: ExecApprovalRequest;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
|
|
@ -356,6 +500,24 @@ function createExpiredContainer(params: {
|
|||
});
|
||||
}
|
||||
|
||||
function createPluginExpiredContainer(params: {
|
||||
request: PluginApprovalRequest;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
}): ExecApprovalContainer {
|
||||
return new ExecApprovalContainer({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
title: "Plugin Approval: Expired",
|
||||
description: "This approval request has expired.",
|
||||
commandPreview: formatCommandPreview(params.request.request.title, 700),
|
||||
commandSecondaryPreview: formatOptionalCommandPreview(params.request.request.description, 1000),
|
||||
metadataLines: buildPluginApprovalMetadataLines(params.request),
|
||||
footer: `ID: ${params.request.id}`,
|
||||
accentColor: "#99AAB5",
|
||||
});
|
||||
}
|
||||
|
||||
export type DiscordExecApprovalHandlerOpts = {
|
||||
token: string;
|
||||
accountId: string;
|
||||
|
|
@ -380,7 +542,7 @@ export type DiscordExecApprovalHandlerOpts = {
|
|||
export class DiscordExecApprovalHandler {
|
||||
private gatewayClient: gatewayRuntime.GatewayClient | null = null;
|
||||
private pending = new Map<string, PendingApproval>();
|
||||
private requestCache = new Map<string, ExecApprovalRequest>();
|
||||
private requestCache = new Map<string, CachedApprovalRequest>();
|
||||
private opts: DiscordExecApprovalHandlerOpts;
|
||||
private started = false;
|
||||
|
||||
|
|
@ -388,7 +550,7 @@ export class DiscordExecApprovalHandler {
|
|||
this.opts = opts;
|
||||
}
|
||||
|
||||
shouldHandle(request: ExecApprovalRequest): boolean {
|
||||
shouldHandle(request: ExecApprovalRequest | PluginApprovalRequest): boolean {
|
||||
const config = this.opts.config;
|
||||
if (!config.enabled) {
|
||||
return false;
|
||||
|
|
@ -397,7 +559,7 @@ export class DiscordExecApprovalHandler {
|
|||
return false;
|
||||
}
|
||||
|
||||
const requestAccountId = resolveExecApprovalAccountId({
|
||||
const requestAccountId = resolveApprovalAccountId({
|
||||
cfg: this.opts.cfg,
|
||||
request,
|
||||
});
|
||||
|
|
@ -410,17 +572,18 @@ export class DiscordExecApprovalHandler {
|
|||
|
||||
// Check agent filter
|
||||
if (config.agentFilter?.length) {
|
||||
if (!request.request.agentId) {
|
||||
const agentId = resolveApprovalAgentId(request);
|
||||
if (!agentId) {
|
||||
return false;
|
||||
}
|
||||
if (!config.agentFilter.includes(request.request.agentId)) {
|
||||
if (!config.agentFilter.includes(agentId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check session filter (substring match)
|
||||
if (config.sessionFilter?.length) {
|
||||
const session = request.request.sessionKey;
|
||||
const session = resolveApprovalSessionKey(request);
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -503,32 +666,54 @@ export class DiscordExecApprovalHandler {
|
|||
if (evt.event === "exec.approval.requested") {
|
||||
const request = evt.payload as ExecApprovalRequest;
|
||||
void this.handleApprovalRequested(request);
|
||||
} else if (evt.event === "plugin.approval.requested") {
|
||||
const request = evt.payload as PluginApprovalRequest;
|
||||
void this.handleApprovalRequested(request);
|
||||
} else if (evt.event === "exec.approval.resolved") {
|
||||
const resolved = evt.payload as ExecApprovalResolved;
|
||||
void this.handleApprovalResolved(resolved);
|
||||
} else if (evt.event === "plugin.approval.resolved") {
|
||||
const resolved = evt.payload as PluginApprovalResolved;
|
||||
void this.handleApprovalResolved(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleApprovalRequested(request: ExecApprovalRequest): Promise<void> {
|
||||
private async handleApprovalRequested(
|
||||
request: ExecApprovalRequest | PluginApprovalRequest,
|
||||
): Promise<void> {
|
||||
if (!this.shouldHandle(request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
logDebug(`discord exec approvals: received request ${request.id}`);
|
||||
|
||||
this.requestCache.set(request.id, request);
|
||||
const pluginRequest: PluginApprovalRequest | null = isPluginApprovalRequest(request)
|
||||
? request
|
||||
: null;
|
||||
logDebug(
|
||||
`discord exec approvals: received ${pluginRequest ? "plugin" : "exec"} request ${request.id}`,
|
||||
);
|
||||
let container: ExecApprovalContainer;
|
||||
if (pluginRequest) {
|
||||
this.requestCache.set(request.id, { kind: "plugin", request: pluginRequest });
|
||||
container = createPluginApprovalRequestContainer({
|
||||
request: pluginRequest,
|
||||
cfg: this.opts.cfg,
|
||||
accountId: this.opts.accountId,
|
||||
actionRow: new ExecApprovalActionRow(request.id),
|
||||
});
|
||||
} else {
|
||||
const execRequest = request as ExecApprovalRequest;
|
||||
this.requestCache.set(request.id, { kind: "exec", request: execRequest });
|
||||
container = createExecApprovalRequestContainer({
|
||||
request: execRequest,
|
||||
cfg: this.opts.cfg,
|
||||
accountId: this.opts.accountId,
|
||||
actionRow: new ExecApprovalActionRow(request.id),
|
||||
});
|
||||
}
|
||||
|
||||
const { rest, request: discordRequest } = (
|
||||
this.opts.__testing?.createDiscordClient ?? sendShared.createDiscordClient
|
||||
)({ token: this.opts.token, accountId: this.opts.accountId }, this.opts.cfg);
|
||||
|
||||
const actionRow = new ExecApprovalActionRow(request.id);
|
||||
const container = createExecApprovalRequestContainer({
|
||||
request,
|
||||
cfg: this.opts.cfg,
|
||||
accountId: this.opts.accountId,
|
||||
actionRow,
|
||||
});
|
||||
const payload = buildExecApprovalPayload(container);
|
||||
const body = sendShared.stripUndefinedFields(serializePayload(payload));
|
||||
|
||||
|
|
@ -536,10 +721,9 @@ export class DiscordExecApprovalHandler {
|
|||
const sendToDm = target === "dm" || target === "both";
|
||||
const sendToChannel = target === "channel" || target === "both";
|
||||
let fallbackToDm = false;
|
||||
const sessionKey = resolveApprovalSessionKey(request);
|
||||
const originatingChannelId =
|
||||
request.request.sessionKey && target === "dm"
|
||||
? extractDiscordChannelId(request.request.sessionKey)
|
||||
: null;
|
||||
sessionKey && target === "dm" ? extractDiscordChannelId(sessionKey) : null;
|
||||
|
||||
if (target === "dm" && originatingChannelId) {
|
||||
try {
|
||||
|
|
@ -557,7 +741,7 @@ export class DiscordExecApprovalHandler {
|
|||
|
||||
// Send to originating channel if configured
|
||||
if (sendToChannel) {
|
||||
const channelId = extractDiscordChannelId(request.request.sessionKey);
|
||||
const channelId = extractDiscordChannelId(sessionKey);
|
||||
if (channelId) {
|
||||
try {
|
||||
const message = (await discordRequest(
|
||||
|
|
@ -588,7 +772,7 @@ export class DiscordExecApprovalHandler {
|
|||
} else {
|
||||
if (!sendToDm) {
|
||||
logError(
|
||||
`discord exec approvals: target is "channel" but could not extract channel id from session key "${request.request.sessionKey ?? "(none)"}" — falling back to DM delivery for approval ${request.id}`,
|
||||
`discord exec approvals: target is "channel" but could not extract channel id from session key "${sessionKey ?? "(none)"}" — falling back to DM delivery for approval ${request.id}`,
|
||||
);
|
||||
fallbackToDm = true;
|
||||
} else {
|
||||
|
|
@ -658,24 +842,37 @@ export class DiscordExecApprovalHandler {
|
|||
}
|
||||
}
|
||||
|
||||
private async handleApprovalResolved(resolved: ExecApprovalResolved): Promise<void> {
|
||||
private async handleApprovalResolved(
|
||||
resolved: ExecApprovalResolved | PluginApprovalResolved,
|
||||
): Promise<void> {
|
||||
// Clean up all pending entries for this approval (channel + dm)
|
||||
const request = this.requestCache.get(resolved.id);
|
||||
const cached = this.requestCache.get(resolved.id);
|
||||
this.requestCache.delete(resolved.id);
|
||||
|
||||
if (!request) {
|
||||
if (!cached) {
|
||||
return;
|
||||
}
|
||||
|
||||
logDebug(`discord exec approvals: resolved ${resolved.id} with ${resolved.decision}`);
|
||||
logDebug(
|
||||
`discord exec approvals: resolved ${cached.kind} ${resolved.id} with ${resolved.decision}`,
|
||||
);
|
||||
|
||||
const container = createResolvedContainer({
|
||||
request,
|
||||
decision: resolved.decision,
|
||||
resolvedBy: resolved.resolvedBy,
|
||||
cfg: this.opts.cfg,
|
||||
accountId: this.opts.accountId,
|
||||
});
|
||||
const container =
|
||||
cached.kind === "plugin"
|
||||
? createPluginResolvedContainer({
|
||||
request: cached.request,
|
||||
decision: resolved.decision,
|
||||
resolvedBy: resolved.resolvedBy,
|
||||
cfg: this.opts.cfg,
|
||||
accountId: this.opts.accountId,
|
||||
})
|
||||
: createExecResolvedContainer({
|
||||
request: cached.request,
|
||||
decision: resolved.decision,
|
||||
resolvedBy: resolved.resolvedBy,
|
||||
cfg: this.opts.cfg,
|
||||
accountId: this.opts.accountId,
|
||||
});
|
||||
|
||||
for (const suffix of [":channel", ":dm", ""]) {
|
||||
const key = `${resolved.id}${suffix}`;
|
||||
|
|
@ -703,7 +900,7 @@ export class DiscordExecApprovalHandler {
|
|||
|
||||
this.pending.delete(key);
|
||||
|
||||
const request = this.requestCache.get(approvalId);
|
||||
const cached = this.requestCache.get(approvalId);
|
||||
|
||||
// Only clean up requestCache if no other pending entries exist for this approval
|
||||
const hasOtherPending =
|
||||
|
|
@ -714,17 +911,26 @@ export class DiscordExecApprovalHandler {
|
|||
this.requestCache.delete(approvalId);
|
||||
}
|
||||
|
||||
if (!request) {
|
||||
if (!cached) {
|
||||
return;
|
||||
}
|
||||
|
||||
logDebug(`discord exec approvals: timeout for ${approvalId} (${source ?? "default"})`);
|
||||
logDebug(
|
||||
`discord exec approvals: timeout for ${cached.kind} ${approvalId} (${source ?? "default"})`,
|
||||
);
|
||||
|
||||
const container = createExpiredContainer({
|
||||
request,
|
||||
cfg: this.opts.cfg,
|
||||
accountId: this.opts.accountId,
|
||||
});
|
||||
const container =
|
||||
cached.kind === "plugin"
|
||||
? createPluginExpiredContainer({
|
||||
request: cached.request,
|
||||
cfg: this.opts.cfg,
|
||||
accountId: this.opts.accountId,
|
||||
})
|
||||
: createExecExpiredContainer({
|
||||
request: cached.request,
|
||||
cfg: this.opts.cfg,
|
||||
accountId: this.opts.accountId,
|
||||
});
|
||||
await this.finalizeMessage(pending.discordChannelId, pending.discordMessageId, container);
|
||||
}
|
||||
|
||||
|
|
@ -782,10 +988,14 @@ export class DiscordExecApprovalHandler {
|
|||
return false;
|
||||
}
|
||||
|
||||
logDebug(`discord exec approvals: resolving ${approvalId} with ${decision}`);
|
||||
const method =
|
||||
resolveApprovalKindFromId(approvalId) === "plugin"
|
||||
? "plugin.approval.resolve"
|
||||
: "exec.approval.resolve";
|
||||
logDebug(`discord exec approvals: resolving ${approvalId} with ${decision} via ${method}`);
|
||||
|
||||
try {
|
||||
await this.gatewayClient.request("exec.approval.resolve", {
|
||||
await this.gatewayClient.request(method, {
|
||||
id: approvalId,
|
||||
decision,
|
||||
});
|
||||
|
|
@ -838,7 +1048,7 @@ export class ExecApprovalButton extends Button {
|
|||
if (!approvers.some((id) => String(id) === userId)) {
|
||||
try {
|
||||
await interaction.reply({
|
||||
content: "⛔ You are not authorized to approve exec requests.",
|
||||
content: "⛔ You are not authorized to approve requests.",
|
||||
ephemeral: true,
|
||||
});
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,9 @@ function buildTelegramExecApprovalButtonsForDecisions(
|
|||
const primaryRow: Array<{ text: string; callback_data: string }> = [
|
||||
{ text: "Allow Once", callback_data: allowOnce },
|
||||
];
|
||||
const allowAlways = `/approve ${approvalId} allow-always`;
|
||||
// Use a shorter decision alias so full plugin:<uuid> IDs still fit Telegram's
|
||||
// 64-byte callback_data limit for the "Allow Always" action.
|
||||
const allowAlways = `/approve ${approvalId} always`;
|
||||
if (allowedDecisions.includes("allow-always") && fitsCallbackData(allowAlways)) {
|
||||
primaryRow.push({ text: "Allow Always", callback_data: allowAlways });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { PluginApprovalRequest } from "../../../src/infra/plugin-approvals.js";
|
||||
import type { PluginRuntime } from "../../../src/plugins/runtime/types.js";
|
||||
import { createStartAccountContext } from "../../../test/helpers/extensions/start-account-context.js";
|
||||
import type { ResolvedTelegramAccount } from "./accounts.js";
|
||||
|
|
@ -144,6 +145,26 @@ function installSendMessageRuntime(
|
|||
return sendMessageTelegram;
|
||||
}
|
||||
|
||||
function createPluginApprovalRequest(
|
||||
overrides: Partial<PluginApprovalRequest["request"]> = {},
|
||||
): PluginApprovalRequest {
|
||||
return {
|
||||
id: "plugin:12345678-1234-1234-1234-1234567890ab",
|
||||
request: {
|
||||
title: "Sensitive plugin action",
|
||||
description: "The plugin requested a sensitive operation.",
|
||||
severity: "warning",
|
||||
toolName: "plugin.tool",
|
||||
pluginId: "plugin-test",
|
||||
agentId: "agent-main",
|
||||
sessionKey: "agent:agent-main:telegram:12345",
|
||||
...overrides,
|
||||
},
|
||||
createdAtMs: 1_000,
|
||||
expiresAtMs: 61_000,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
|
@ -468,6 +489,34 @@ describe("telegramPlugin duplicate token guard", () => {
|
|||
expect(result).toMatchObject({ channel: "telegram", messageId: "tg-4" });
|
||||
});
|
||||
|
||||
it("builds plugin approval pending payload with callback ids that preserve allow-always", () => {
|
||||
const request = createPluginApprovalRequest();
|
||||
const payload = telegramPlugin.execApprovals?.buildPluginPendingPayload?.({
|
||||
cfg: createCfg(),
|
||||
request,
|
||||
target: { channel: "telegram", to: "12345" },
|
||||
nowMs: 2_000,
|
||||
});
|
||||
|
||||
expect(payload?.text).toContain("Plugin approval required");
|
||||
const channelData = payload?.channelData as
|
||||
| {
|
||||
execApproval?: { approvalId?: string; approvalSlug?: string };
|
||||
telegram?: { buttons?: Array<Array<{ text: string; callback_data: string }>> };
|
||||
}
|
||||
| undefined;
|
||||
expect(channelData?.execApproval?.approvalId).toBe(request.id);
|
||||
expect(channelData?.execApproval?.approvalSlug).toBe(request.id);
|
||||
const buttons = channelData?.telegram?.buttons;
|
||||
expect(buttons).toBeDefined();
|
||||
expect(buttons?.[0]?.some((button) => button.text === "Allow Always")).toBe(true);
|
||||
for (const row of buttons ?? []) {
|
||||
for (const button of row) {
|
||||
expect(Buffer.byteLength(button.callback_data, "utf8")).toBeLessThanOrEqual(64);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores accounts with missing tokens during duplicate-token checks", async () => {
|
||||
const cfg = createCfg();
|
||||
cfg.channels!.telegram!.accounts!.ops = {} as never;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/
|
|||
import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result";
|
||||
import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
|
||||
import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
|
||||
import { buildPluginApprovalRequestMessage } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import {
|
||||
resolveOutboundSendDep,
|
||||
type OutboundSendDeps,
|
||||
|
|
@ -39,6 +40,7 @@ import {
|
|||
type ResolvedTelegramAccount,
|
||||
} from "./accounts.js";
|
||||
import { resolveTelegramAutoThreadId } from "./action-threading.js";
|
||||
import { buildTelegramExecApprovalButtons } from "./approval-buttons.js";
|
||||
import { auditTelegramGroupMembership, collectTelegramUnmentionedGroupIds } from "./audit.js";
|
||||
import { buildTelegramGroupPeerId } from "./bot/helpers.js";
|
||||
import {
|
||||
|
|
@ -404,6 +406,32 @@ export const telegramPlugin = createChatChannelPlugin({
|
|||
...(Number.isFinite(threadId) ? { messageThreadId: threadId } : {}),
|
||||
}).catch(() => {});
|
||||
},
|
||||
buildPluginPendingPayload: ({ request, nowMs }) => {
|
||||
const text = buildPluginApprovalRequestMessage(request, nowMs);
|
||||
const buttons = buildTelegramExecApprovalButtons(request.id);
|
||||
const execApproval = {
|
||||
approvalId: request.id,
|
||||
approvalSlug: request.id,
|
||||
allowedDecisions: ["allow-once", "allow-always", "deny"] as const,
|
||||
};
|
||||
if (!buttons) {
|
||||
return {
|
||||
text,
|
||||
channelData: {
|
||||
execApproval,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
text,
|
||||
channelData: {
|
||||
execApproval,
|
||||
telegram: {
|
||||
buttons,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
directory: createChannelDirectoryAdapter({
|
||||
listPeers: async (params) => listTelegramDirectoryPeersFromConfig(params),
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ describe("TelegramExecApprovalHandler", () => {
|
|||
},
|
||||
{
|
||||
text: "Allow Always",
|
||||
callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-always",
|
||||
callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 always",
|
||||
},
|
||||
],
|
||||
[
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ describe("telegram approval buttons", () => {
|
|||
expect(buildTelegramExecApprovalButtons("fbd8daf7")).toEqual([
|
||||
[
|
||||
{ text: "Allow Once", callback_data: "/approve fbd8daf7 allow-once" },
|
||||
{ text: "Allow Always", callback_data: "/approve fbd8daf7 allow-always" },
|
||||
{ text: "Allow Always", callback_data: "/approve fbd8daf7 always" },
|
||||
],
|
||||
[{ text: "Deny", callback_data: "/approve fbd8daf7 deny" }],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ vi.mock("../plugins/hook-runner-global.js", async (importOriginal) => {
|
|||
getGlobalHookRunner: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock("./tools/gateway.js", () => ({
|
||||
callGatewayTool: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockGetGlobalHookRunner = vi.mocked(getGlobalHookRunner);
|
||||
|
||||
|
|
@ -325,3 +328,525 @@ describe("before_tool_call loop detection behavior", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("before_tool_call requireApproval handling", () => {
|
||||
let runBeforeToolCallHook: (typeof import("./pi-tools.before-tool-call.js"))["runBeforeToolCallHook"];
|
||||
let hookRunner: {
|
||||
hasHooks: ReturnType<typeof vi.fn>;
|
||||
runBeforeToolCall: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ runBeforeToolCallHook } = await import("./pi-tools.before-tool-call.js"));
|
||||
|
||||
resetDiagnosticSessionStateForTest();
|
||||
resetDiagnosticEventsForTest();
|
||||
hookRunner = {
|
||||
hasHooks: vi.fn().mockReturnValue(true),
|
||||
runBeforeToolCall: vi.fn(),
|
||||
};
|
||||
const { getGlobalHookRunner: currentGetGlobalHookRunner } =
|
||||
await import("../plugins/hook-runner-global.js");
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
vi.mocked(currentGetGlobalHookRunner).mockReturnValue(hookRunner as any);
|
||||
// Keep the global singleton aligned as a fallback in case another setup path
|
||||
// preloads hook-runner-global before this test's module reset/mocks take effect.
|
||||
const hookRunnerGlobalStateKey = Symbol.for("openclaw.plugins.hook-runner-global-state");
|
||||
const hookRunnerGlobalState = globalThis as Record<
|
||||
symbol,
|
||||
{ hookRunner: unknown; registry?: unknown } | undefined
|
||||
>;
|
||||
if (!hookRunnerGlobalState[hookRunnerGlobalStateKey]) {
|
||||
hookRunnerGlobalState[hookRunnerGlobalStateKey] = {
|
||||
hookRunner: null,
|
||||
registry: null,
|
||||
};
|
||||
}
|
||||
hookRunnerGlobalState[hookRunnerGlobalStateKey].hookRunner = hookRunner;
|
||||
// Clear gateway mock state between tests to prevent call-count leaks.
|
||||
const { callGatewayTool } = await import("./tools/gateway.js");
|
||||
vi.mocked(callGatewayTool).mockReset();
|
||||
});
|
||||
|
||||
it("blocks without triggering approval when both block and requireApproval are set", async () => {
|
||||
const { callGatewayTool } = await import("./tools/gateway.js");
|
||||
const mockCallGateway = vi.mocked(callGatewayTool);
|
||||
|
||||
hookRunner.runBeforeToolCall.mockResolvedValue({
|
||||
block: true,
|
||||
blockReason: "Blocked by security plugin",
|
||||
requireApproval: {
|
||||
title: "Should not reach gateway",
|
||||
description: "This approval should be skipped",
|
||||
pluginId: "lower-priority-plugin",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runBeforeToolCallHook({
|
||||
toolName: "bash",
|
||||
params: { command: "rm -rf" },
|
||||
ctx: { agentId: "main", sessionKey: "main" },
|
||||
});
|
||||
|
||||
expect(result.blocked).toBe(true);
|
||||
expect(result).toHaveProperty("reason", "Blocked by security plugin");
|
||||
expect(mockCallGateway).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls gateway RPC and unblocks on allow-once", async () => {
|
||||
const { callGatewayTool } = await import("./tools/gateway.js");
|
||||
const mockCallGateway = vi.mocked(callGatewayTool);
|
||||
|
||||
hookRunner.runBeforeToolCall.mockResolvedValue({
|
||||
requireApproval: {
|
||||
title: "Sensitive",
|
||||
description: "Sensitive op",
|
||||
pluginId: "sage",
|
||||
},
|
||||
});
|
||||
|
||||
// First call: plugin.approval.request → returns server-generated id
|
||||
mockCallGateway.mockResolvedValueOnce({ id: "server-id-1", status: "accepted" });
|
||||
// Second call: plugin.approval.waitDecision → returns allow-once
|
||||
mockCallGateway.mockResolvedValueOnce({ id: "server-id-1", decision: "allow-once" });
|
||||
|
||||
const result = await runBeforeToolCallHook({
|
||||
toolName: "bash",
|
||||
params: { command: "rm -rf" },
|
||||
ctx: { agentId: "main", sessionKey: "main" },
|
||||
});
|
||||
|
||||
expect(result.blocked).toBe(false);
|
||||
expect(mockCallGateway).toHaveBeenCalledTimes(2);
|
||||
expect(mockCallGateway).toHaveBeenCalledWith(
|
||||
"plugin.approval.request",
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ twoPhase: true }),
|
||||
{ expectFinal: false },
|
||||
);
|
||||
expect(mockCallGateway).toHaveBeenCalledWith(
|
||||
"plugin.approval.waitDecision",
|
||||
expect.any(Object),
|
||||
{ id: "server-id-1" },
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks on deny decision", async () => {
|
||||
const { callGatewayTool } = await import("./tools/gateway.js");
|
||||
const mockCallGateway = vi.mocked(callGatewayTool);
|
||||
|
||||
hookRunner.runBeforeToolCall.mockResolvedValue({
|
||||
requireApproval: {
|
||||
title: "Dangerous",
|
||||
description: "Dangerous op",
|
||||
},
|
||||
});
|
||||
|
||||
mockCallGateway.mockResolvedValueOnce({ id: "server-id-2", status: "accepted" });
|
||||
mockCallGateway.mockResolvedValueOnce({ id: "server-id-2", decision: "deny" });
|
||||
|
||||
const result = await runBeforeToolCallHook({
|
||||
toolName: "bash",
|
||||
params: {},
|
||||
ctx: { agentId: "main", sessionKey: "main" },
|
||||
});
|
||||
|
||||
expect(result.blocked).toBe(true);
|
||||
expect(result).toHaveProperty("reason", "Denied by user");
|
||||
});
|
||||
|
||||
it("blocks on timeout with default deny behavior", async () => {
|
||||
const { callGatewayTool } = await import("./tools/gateway.js");
|
||||
const mockCallGateway = vi.mocked(callGatewayTool);
|
||||
|
||||
hookRunner.runBeforeToolCall.mockResolvedValue({
|
||||
requireApproval: {
|
||||
title: "Timeout test",
|
||||
description: "Will time out",
|
||||
},
|
||||
});
|
||||
|
||||
mockCallGateway.mockResolvedValueOnce({ id: "server-id-3", status: "accepted" });
|
||||
mockCallGateway.mockResolvedValueOnce({ id: "server-id-3", decision: null });
|
||||
|
||||
const result = await runBeforeToolCallHook({
|
||||
toolName: "bash",
|
||||
params: {},
|
||||
ctx: { agentId: "main", sessionKey: "main" },
|
||||
});
|
||||
|
||||
expect(result.blocked).toBe(true);
|
||||
expect(result).toHaveProperty("reason", "Approval timed out");
|
||||
});
|
||||
|
||||
it("allows on timeout when timeoutBehavior is allow and preserves hook params", async () => {
|
||||
const { callGatewayTool } = await import("./tools/gateway.js");
|
||||
const mockCallGateway = vi.mocked(callGatewayTool);
|
||||
|
||||
hookRunner.runBeforeToolCall.mockResolvedValue({
|
||||
params: { command: "safe-command" },
|
||||
requireApproval: {
|
||||
title: "Lenient timeout",
|
||||
description: "Should allow on timeout",
|
||||
timeoutBehavior: "allow",
|
||||
},
|
||||
});
|
||||
|
||||
mockCallGateway.mockResolvedValueOnce({ id: "server-id-4", status: "accepted" });
|
||||
mockCallGateway.mockResolvedValueOnce({ id: "server-id-4", decision: null });
|
||||
|
||||
const result = await runBeforeToolCallHook({
|
||||
toolName: "bash",
|
||||
params: { command: "rm -rf /" },
|
||||
ctx: { agentId: "main", sessionKey: "main" },
|
||||
});
|
||||
|
||||
expect(result.blocked).toBe(false);
|
||||
if (!result.blocked) {
|
||||
expect(result.params).toEqual({ command: "safe-command" });
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to block on gateway error", async () => {
|
||||
const { callGatewayTool } = await import("./tools/gateway.js");
|
||||
const mockCallGateway = vi.mocked(callGatewayTool);
|
||||
|
||||
hookRunner.runBeforeToolCall.mockResolvedValue({
|
||||
requireApproval: {
|
||||
title: "Gateway down",
|
||||
description: "Gateway is unavailable",
|
||||
},
|
||||
});
|
||||
|
||||
mockCallGateway.mockRejectedValueOnce(new Error("unknown method plugin.approval.request"));
|
||||
|
||||
const result = await runBeforeToolCallHook({
|
||||
toolName: "bash",
|
||||
params: {},
|
||||
ctx: { agentId: "main", sessionKey: "main" },
|
||||
});
|
||||
|
||||
expect(result.blocked).toBe(true);
|
||||
expect(result).toHaveProperty("reason", "Plugin approval required (gateway unavailable)");
|
||||
});
|
||||
|
||||
it("blocks when gateway returns no id", async () => {
|
||||
const { callGatewayTool } = await import("./tools/gateway.js");
|
||||
const mockCallGateway = vi.mocked(callGatewayTool);
|
||||
|
||||
hookRunner.runBeforeToolCall.mockResolvedValue({
|
||||
requireApproval: {
|
||||
title: "No ID",
|
||||
description: "Registration returns no id",
|
||||
},
|
||||
});
|
||||
|
||||
mockCallGateway.mockResolvedValueOnce({ status: "error" });
|
||||
|
||||
const result = await runBeforeToolCallHook({
|
||||
toolName: "bash",
|
||||
params: {},
|
||||
ctx: { agentId: "main", sessionKey: "main" },
|
||||
});
|
||||
|
||||
expect(result.blocked).toBe(true);
|
||||
expect(result).toHaveProperty("reason", "Registration returns no id");
|
||||
});
|
||||
|
||||
it("blocks on immediate null decision without calling waitDecision even when timeoutBehavior is allow", async () => {
|
||||
const { callGatewayTool } = await import("./tools/gateway.js");
|
||||
const mockCallGateway = vi.mocked(callGatewayTool);
|
||||
const onResolution = vi.fn();
|
||||
|
||||
hookRunner.runBeforeToolCall.mockResolvedValue({
|
||||
requireApproval: {
|
||||
title: "No route",
|
||||
description: "No approval route available",
|
||||
timeoutBehavior: "allow",
|
||||
onResolution,
|
||||
},
|
||||
});
|
||||
|
||||
mockCallGateway.mockResolvedValueOnce({ id: "server-id-immediate", decision: null });
|
||||
|
||||
const result = await runBeforeToolCallHook({
|
||||
toolName: "bash",
|
||||
params: {},
|
||||
ctx: { agentId: "main", sessionKey: "main" },
|
||||
});
|
||||
|
||||
expect(result.blocked).toBe(true);
|
||||
expect(result).toHaveProperty("reason", "Plugin approval unavailable (no approval route)");
|
||||
expect(onResolution).toHaveBeenCalledWith("cancelled");
|
||||
expect(mockCallGateway.mock.calls.map(([method]) => method)).toEqual([
|
||||
"plugin.approval.request",
|
||||
]);
|
||||
});
|
||||
|
||||
it("unblocks immediately when abort signal fires during waitDecision", async () => {
|
||||
const { callGatewayTool } = await import("./tools/gateway.js");
|
||||
const mockCallGateway = vi.mocked(callGatewayTool);
|
||||
|
||||
hookRunner.runBeforeToolCall.mockResolvedValue({
|
||||
requireApproval: {
|
||||
title: "Abortable",
|
||||
description: "Will be aborted",
|
||||
},
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
// First call: plugin.approval.request → accepted
|
||||
mockCallGateway.mockResolvedValueOnce({ id: "server-id-abort", status: "accepted" });
|
||||
// Second call: plugin.approval.waitDecision → never resolves (simulates long wait)
|
||||
mockCallGateway.mockImplementationOnce(
|
||||
() => new Promise(() => {}), // hangs forever
|
||||
);
|
||||
|
||||
// Abort after a short delay
|
||||
setTimeout(() => controller.abort(new Error("run cancelled")), 10);
|
||||
|
||||
const result = await runBeforeToolCallHook({
|
||||
toolName: "bash",
|
||||
params: {},
|
||||
ctx: { agentId: "main", sessionKey: "main" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
expect(result.blocked).toBe(true);
|
||||
expect(result).toHaveProperty("reason", "Approval cancelled (run aborted)");
|
||||
expect(mockCallGateway).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("removes abort listener after waitDecision resolves", async () => {
|
||||
const { callGatewayTool } = await import("./tools/gateway.js");
|
||||
const mockCallGateway = vi.mocked(callGatewayTool);
|
||||
|
||||
hookRunner.runBeforeToolCall.mockResolvedValue({
|
||||
requireApproval: {
|
||||
title: "Cleanup listener",
|
||||
description: "Wait resolves quickly",
|
||||
},
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const removeListenerSpy = vi.spyOn(controller.signal, "removeEventListener");
|
||||
|
||||
mockCallGateway.mockResolvedValueOnce({ id: "server-id-cleanup", status: "accepted" });
|
||||
mockCallGateway.mockResolvedValueOnce({ id: "server-id-cleanup", decision: "allow-once" });
|
||||
|
||||
const result = await runBeforeToolCallHook({
|
||||
toolName: "bash",
|
||||
params: {},
|
||||
ctx: { agentId: "main", sessionKey: "main" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
expect(result.blocked).toBe(false);
|
||||
expect(removeListenerSpy.mock.calls.some(([type]) => type === "abort")).toBe(true);
|
||||
});
|
||||
|
||||
it("calls onResolution with allow-once on approval", async () => {
|
||||
const { callGatewayTool } = await import("./tools/gateway.js");
|
||||
const mockCallGateway = vi.mocked(callGatewayTool);
|
||||
const onResolution = vi.fn();
|
||||
|
||||
hookRunner.runBeforeToolCall.mockResolvedValue({
|
||||
requireApproval: {
|
||||
title: "Needs approval",
|
||||
description: "Check this",
|
||||
onResolution,
|
||||
},
|
||||
});
|
||||
|
||||
mockCallGateway.mockResolvedValueOnce({ id: "server-id-r1", status: "accepted" });
|
||||
mockCallGateway.mockResolvedValueOnce({ id: "server-id-r1", decision: "allow-once" });
|
||||
|
||||
await runBeforeToolCallHook({
|
||||
toolName: "bash",
|
||||
params: {},
|
||||
ctx: { agentId: "main", sessionKey: "main" },
|
||||
});
|
||||
|
||||
expect(onResolution).toHaveBeenCalledWith("allow-once");
|
||||
});
|
||||
|
||||
it("does not await onResolution before returning approval outcome", async () => {
|
||||
const { callGatewayTool } = await import("./tools/gateway.js");
|
||||
const mockCallGateway = vi.mocked(callGatewayTool);
|
||||
const onResolution = vi.fn(() => new Promise<void>(() => {}));
|
||||
|
||||
hookRunner.runBeforeToolCall.mockResolvedValue({
|
||||
requireApproval: {
|
||||
title: "Non-blocking callback",
|
||||
description: "Should not block tool execution",
|
||||
onResolution,
|
||||
},
|
||||
});
|
||||
|
||||
mockCallGateway.mockResolvedValueOnce({ id: "server-id-r1-nonblocking", status: "accepted" });
|
||||
mockCallGateway.mockResolvedValueOnce({
|
||||
id: "server-id-r1-nonblocking",
|
||||
decision: "allow-once",
|
||||
});
|
||||
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
runBeforeToolCallHook({
|
||||
toolName: "bash",
|
||||
params: {},
|
||||
ctx: { agentId: "main", sessionKey: "main" },
|
||||
}),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(
|
||||
() => reject(new Error("runBeforeToolCallHook waited for onResolution")),
|
||||
250,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(result).toEqual({ blocked: false, params: {} });
|
||||
expect(onResolution).toHaveBeenCalledWith("allow-once");
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("calls onResolution with deny on denial", async () => {
|
||||
const { callGatewayTool } = await import("./tools/gateway.js");
|
||||
const mockCallGateway = vi.mocked(callGatewayTool);
|
||||
const onResolution = vi.fn();
|
||||
|
||||
hookRunner.runBeforeToolCall.mockResolvedValue({
|
||||
requireApproval: {
|
||||
title: "Needs approval",
|
||||
description: "Check this",
|
||||
onResolution,
|
||||
},
|
||||
});
|
||||
|
||||
mockCallGateway.mockResolvedValueOnce({ id: "server-id-r2", status: "accepted" });
|
||||
mockCallGateway.mockResolvedValueOnce({ id: "server-id-r2", decision: "deny" });
|
||||
|
||||
await runBeforeToolCallHook({
|
||||
toolName: "bash",
|
||||
params: {},
|
||||
ctx: { agentId: "main", sessionKey: "main" },
|
||||
});
|
||||
|
||||
expect(onResolution).toHaveBeenCalledWith("deny");
|
||||
});
|
||||
|
||||
it("calls onResolution with timeout when decision is null", async () => {
|
||||
const { callGatewayTool } = await import("./tools/gateway.js");
|
||||
const mockCallGateway = vi.mocked(callGatewayTool);
|
||||
const onResolution = vi.fn();
|
||||
|
||||
hookRunner.runBeforeToolCall.mockResolvedValue({
|
||||
requireApproval: {
|
||||
title: "Timeout resolution",
|
||||
description: "Will time out",
|
||||
onResolution,
|
||||
},
|
||||
});
|
||||
|
||||
mockCallGateway.mockResolvedValueOnce({ id: "server-id-r3", status: "accepted" });
|
||||
mockCallGateway.mockResolvedValueOnce({ id: "server-id-r3", decision: null });
|
||||
|
||||
await runBeforeToolCallHook({
|
||||
toolName: "bash",
|
||||
params: {},
|
||||
ctx: { agentId: "main", sessionKey: "main" },
|
||||
});
|
||||
|
||||
expect(onResolution).toHaveBeenCalledWith("timeout");
|
||||
});
|
||||
|
||||
it("calls onResolution with cancelled on gateway error", async () => {
|
||||
const { callGatewayTool } = await import("./tools/gateway.js");
|
||||
const mockCallGateway = vi.mocked(callGatewayTool);
|
||||
const onResolution = vi.fn();
|
||||
|
||||
hookRunner.runBeforeToolCall.mockResolvedValue({
|
||||
requireApproval: {
|
||||
title: "Gateway error",
|
||||
description: "Gateway will fail",
|
||||
onResolution,
|
||||
},
|
||||
});
|
||||
|
||||
mockCallGateway.mockRejectedValueOnce(new Error("gateway down"));
|
||||
|
||||
const result = await runBeforeToolCallHook({
|
||||
toolName: "bash",
|
||||
params: {},
|
||||
ctx: { agentId: "main", sessionKey: "main" },
|
||||
});
|
||||
|
||||
expect(result.blocked).toBe(true);
|
||||
expect(result).toHaveProperty("reason", "Plugin approval required (gateway unavailable)");
|
||||
expect(onResolution).toHaveBeenCalledWith("cancelled");
|
||||
});
|
||||
|
||||
it("calls onResolution with cancelled when abort signal fires", async () => {
|
||||
const { callGatewayTool } = await import("./tools/gateway.js");
|
||||
const mockCallGateway = vi.mocked(callGatewayTool);
|
||||
const onResolution = vi.fn();
|
||||
|
||||
hookRunner.runBeforeToolCall.mockResolvedValue({
|
||||
requireApproval: {
|
||||
title: "Abortable with callback",
|
||||
description: "Will be aborted",
|
||||
onResolution,
|
||||
},
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
mockCallGateway.mockResolvedValueOnce({ id: "server-id-r5", status: "accepted" });
|
||||
mockCallGateway.mockImplementationOnce(
|
||||
() => new Promise(() => {}), // hangs forever
|
||||
);
|
||||
|
||||
setTimeout(() => controller.abort(new Error("run cancelled")), 10);
|
||||
|
||||
const result = await runBeforeToolCallHook({
|
||||
toolName: "bash",
|
||||
params: {},
|
||||
ctx: { agentId: "main", sessionKey: "main" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
expect(result.blocked).toBe(true);
|
||||
expect(result).toHaveProperty("reason", "Approval cancelled (run aborted)");
|
||||
expect(onResolution).toHaveBeenCalledWith("cancelled");
|
||||
});
|
||||
|
||||
it("calls onResolution with cancelled when gateway returns no id", async () => {
|
||||
const { callGatewayTool } = await import("./tools/gateway.js");
|
||||
const mockCallGateway = vi.mocked(callGatewayTool);
|
||||
const onResolution = vi.fn();
|
||||
|
||||
hookRunner.runBeforeToolCall.mockResolvedValue({
|
||||
requireApproval: {
|
||||
title: "No ID",
|
||||
description: "Registration returns no id",
|
||||
onResolution,
|
||||
},
|
||||
});
|
||||
|
||||
mockCallGateway.mockResolvedValueOnce({ status: "error" });
|
||||
|
||||
await runBeforeToolCallHook({
|
||||
toolName: "bash",
|
||||
params: {},
|
||||
ctx: { agentId: "main", sessionKey: "main" },
|
||||
});
|
||||
|
||||
expect(onResolution).toHaveBeenCalledWith("cancelled");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,11 +3,13 @@ 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 { createLazyRuntimeSurface } from "../shared/lazy-runtime.js";
|
||||
import { isPlainObject } from "../utils.js";
|
||||
import { copyChannelAgentToolMeta } from "./channel-tools.js";
|
||||
import { normalizeToolName } from "./tool-policy.js";
|
||||
import type { AnyAgentTool } from "./tools/common.js";
|
||||
import { callGatewayTool } from "./tools/gateway.js";
|
||||
|
||||
export type HookContext = {
|
||||
agentId?: string;
|
||||
|
|
@ -39,6 +41,32 @@ function buildAdjustedParamsKey(params: { runId?: string; toolCallId: string }):
|
|||
return params.toolCallId;
|
||||
}
|
||||
|
||||
function mergeParamsWithApprovalOverrides(
|
||||
originalParams: unknown,
|
||||
approvalParams?: unknown,
|
||||
): unknown {
|
||||
if (approvalParams && isPlainObject(approvalParams)) {
|
||||
if (isPlainObject(originalParams)) {
|
||||
return { ...originalParams, ...approvalParams };
|
||||
}
|
||||
return approvalParams;
|
||||
}
|
||||
return originalParams;
|
||||
}
|
||||
|
||||
function isAbortSignalCancellation(err: unknown, signal?: AbortSignal): boolean {
|
||||
if (!signal?.aborted) {
|
||||
return false;
|
||||
}
|
||||
if (err === signal.reason) {
|
||||
return true;
|
||||
}
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function shouldEmitLoopWarning(state: SessionState, warningKey: string, count: number): boolean {
|
||||
if (!state.toolLoopWarningBuckets) {
|
||||
state.toolLoopWarningBuckets = new Map();
|
||||
|
|
@ -93,6 +121,7 @@ export async function runBeforeToolCallHook(args: {
|
|||
params: unknown;
|
||||
toolCallId?: string;
|
||||
ctx?: HookContext;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<HookOutcome> {
|
||||
const toolName = normalizeToolName(args.toolName || "tool");
|
||||
const params = args.params;
|
||||
|
|
@ -156,18 +185,18 @@ export async function runBeforeToolCallHook(args: {
|
|||
const normalizedParams = isPlainObject(params) ? params : {};
|
||||
const toolContext = {
|
||||
toolName,
|
||||
...(args.ctx?.agentId ? { agentId: args.ctx.agentId } : {}),
|
||||
...(args.ctx?.sessionKey ? { sessionKey: args.ctx.sessionKey } : {}),
|
||||
...(args.ctx?.sessionId ? { sessionId: args.ctx.sessionId } : {}),
|
||||
...(args.ctx?.runId ? { runId: args.ctx.runId } : {}),
|
||||
...(args.toolCallId ? { toolCallId: args.toolCallId } : {}),
|
||||
...(args.ctx?.agentId && { agentId: args.ctx.agentId }),
|
||||
...(args.ctx?.sessionKey && { sessionKey: args.ctx.sessionKey }),
|
||||
...(args.ctx?.sessionId && { sessionId: args.ctx.sessionId }),
|
||||
...(args.ctx?.runId && { runId: args.ctx.runId }),
|
||||
...(args.toolCallId && { toolCallId: args.toolCallId }),
|
||||
};
|
||||
const hookResult = await hookRunner.runBeforeToolCall(
|
||||
{
|
||||
toolName,
|
||||
params: normalizedParams,
|
||||
...(args.ctx?.runId ? { runId: args.ctx.runId } : {}),
|
||||
...(args.toolCallId ? { toolCallId: args.toolCallId } : {}),
|
||||
...(args.ctx?.runId && { runId: args.ctx.runId }),
|
||||
...(args.toolCallId && { toolCallId: args.toolCallId }),
|
||||
},
|
||||
toolContext,
|
||||
);
|
||||
|
|
@ -179,11 +208,152 @@ export async function runBeforeToolCallHook(args: {
|
|||
};
|
||||
}
|
||||
|
||||
if (hookResult?.params && isPlainObject(hookResult.params)) {
|
||||
if (isPlainObject(params)) {
|
||||
return { blocked: false, params: { ...params, ...hookResult.params } };
|
||||
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 = await callGatewayTool<{
|
||||
id?: string;
|
||||
status?: string;
|
||||
decision?: string | null;
|
||||
}>(
|
||||
"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 = callGatewayTool<{
|
||||
id?: string;
|
||||
decision?: string | null;
|
||||
}>(
|
||||
"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<never>((_, 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, falling back to block: ${String(err)}`);
|
||||
return {
|
||||
blocked: true,
|
||||
reason: "Plugin approval required (gateway unavailable)",
|
||||
};
|
||||
}
|
||||
return { blocked: false, params: hookResult.params };
|
||||
}
|
||||
|
||||
if (hookResult?.params) {
|
||||
return {
|
||||
blocked: false,
|
||||
params: mergeParamsWithApprovalOverrides(params, hookResult.params),
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
const toolCallId = args.toolCallId ? ` toolCallId=${args.toolCallId}` : "";
|
||||
|
|
@ -210,6 +380,7 @@ export function wrapToolWithBeforeToolCallHook(
|
|||
params,
|
||||
toolCallId,
|
||||
ctx,
|
||||
signal,
|
||||
});
|
||||
if (outcome.blocked) {
|
||||
throw new Error(outcome.reason);
|
||||
|
|
@ -273,5 +444,6 @@ export const __testing = {
|
|||
buildAdjustedParamsKey,
|
||||
adjustedParamsByToolCallId,
|
||||
runBeforeToolCallHook,
|
||||
mergeParamsWithApprovalOverrides,
|
||||
isPlainObject,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { callGateway } from "../../gateway/call.js";
|
||||
import { ErrorCodes } from "../../gateway/protocol/index.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import {
|
||||
isTelegramExecApprovalApprover,
|
||||
|
|
@ -72,6 +73,39 @@ function buildResolvedByLabel(params: Parameters<CommandHandler>[0]): string {
|
|||
return `${channel}:${sender}`;
|
||||
}
|
||||
|
||||
function readErrorCode(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value : null;
|
||||
}
|
||||
|
||||
function readApprovalNotFoundDetailsReason(value: unknown): string | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const reason = (value as { reason?: unknown }).reason;
|
||||
return typeof reason === "string" && reason.trim() ? reason : null;
|
||||
}
|
||||
|
||||
function isApprovalNotFoundError(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
const gatewayCode = readErrorCode((err as { gatewayCode?: unknown }).gatewayCode);
|
||||
if (gatewayCode === ErrorCodes.APPROVAL_NOT_FOUND) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const detailsReason = readApprovalNotFoundDetailsReason((err as { details?: unknown }).details);
|
||||
if (
|
||||
gatewayCode === ErrorCodes.INVALID_REQUEST &&
|
||||
detailsReason === ErrorCodes.APPROVAL_NOT_FOUND
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Legacy server/client combinations may only include the message text.
|
||||
return /unknown or expired approval id/i.test(err.message);
|
||||
}
|
||||
|
||||
export const handleApproveCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) {
|
||||
return null;
|
||||
|
|
@ -91,26 +125,39 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm
|
|||
if (!parsed.ok) {
|
||||
return { shouldContinue: false, reply: { text: parsed.error } };
|
||||
}
|
||||
const isPluginId = parsed.id.startsWith("plugin:");
|
||||
|
||||
if (params.command.channel === "telegram") {
|
||||
if (
|
||||
!isTelegramExecApprovalClientEnabled({ cfg: params.cfg, accountId: params.ctx.AccountId })
|
||||
) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "❌ Telegram exec approvals are not enabled for this bot account." },
|
||||
};
|
||||
const telegramApproverContext = {
|
||||
cfg: params.cfg,
|
||||
accountId: params.ctx.AccountId,
|
||||
senderId: params.command.senderId,
|
||||
};
|
||||
|
||||
if (!isPluginId) {
|
||||
if (
|
||||
!isTelegramExecApprovalClientEnabled({ cfg: params.cfg, accountId: params.ctx.AccountId })
|
||||
) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "❌ Telegram exec approvals are not enabled for this bot account." },
|
||||
};
|
||||
}
|
||||
if (!isTelegramExecApprovalApprover(telegramApproverContext)) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "❌ You are not authorized to approve exec requests on Telegram." },
|
||||
};
|
||||
}
|
||||
}
|
||||
if (
|
||||
!isTelegramExecApprovalApprover({
|
||||
cfg: params.cfg,
|
||||
accountId: params.ctx.AccountId,
|
||||
senderId: params.command.senderId,
|
||||
})
|
||||
) {
|
||||
|
||||
// Keep plugin-ID routing independent from exec approval client enablement so
|
||||
// forwarded plugin approvals remain resolvable, but still require explicit
|
||||
// Telegram approver membership for security parity.
|
||||
if (isPluginId && !isTelegramExecApprovalApprover(telegramApproverContext)) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "❌ You are not authorized to approve exec requests on Telegram." },
|
||||
reply: { text: "❌ You are not authorized to approve plugin requests on Telegram." },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -125,25 +172,51 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm
|
|||
}
|
||||
|
||||
const resolvedBy = buildResolvedByLabel(params);
|
||||
try {
|
||||
const callApprovalMethod = async (method: string): Promise<void> => {
|
||||
await callGateway({
|
||||
method: "exec.approval.resolve",
|
||||
method,
|
||||
params: { id: parsed.id, decision: parsed.decision },
|
||||
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
|
||||
clientDisplayName: `Chat approval (${resolvedBy})`,
|
||||
mode: GATEWAY_CLIENT_MODES.BACKEND,
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `❌ Failed to submit approval: ${String(err)}`,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Plugin approval IDs are kind-prefixed (`plugin:<uuid>`); route directly when detected.
|
||||
// Unprefixed IDs try exec first, then fall back to plugin for backward compat.
|
||||
if (isPluginId) {
|
||||
try {
|
||||
await callApprovalMethod("plugin.approval.resolve");
|
||||
} catch (err) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `❌ Failed to submit approval: ${String(err)}` },
|
||||
};
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await callApprovalMethod("exec.approval.resolve");
|
||||
} catch (err) {
|
||||
if (isApprovalNotFoundError(err)) {
|
||||
try {
|
||||
await callApprovalMethod("plugin.approval.resolve");
|
||||
} catch (pluginErr) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `❌ Failed to submit approval: ${String(pluginErr)}` },
|
||||
};
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `❌ Failed to submit approval: ${String(err)}` },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `✅ Exec approval ${parsed.decision} submitted for ${parsed.id}.` },
|
||||
reply: { text: `✅ Approval ${parsed.decision} submitted for ${parsed.id}.` },
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -345,7 +345,7 @@ describe("/approve command", () => {
|
|||
|
||||
function createTelegramApproveCfg(
|
||||
execApprovals: {
|
||||
enabled: true;
|
||||
enabled: boolean;
|
||||
approvers: string[];
|
||||
target: "dm";
|
||||
} | null = { enabled: true, approvers: ["123"], target: "dm" },
|
||||
|
|
@ -383,7 +383,7 @@ describe("/approve command", () => {
|
|||
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Exec approval allow-once submitted");
|
||||
expect(result.reply?.text).toContain("Approval allow-once submitted");
|
||||
expect(callGatewayMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "exec.approval.resolve",
|
||||
|
|
@ -405,7 +405,7 @@ describe("/approve command", () => {
|
|||
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Exec approval allow-once submitted");
|
||||
expect(result.reply?.text).toContain("Approval allow-once submitted");
|
||||
expect(callGatewayMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "exec.approval.resolve",
|
||||
|
|
@ -439,9 +439,12 @@ describe("/approve command", () => {
|
|||
Surface: "telegram",
|
||||
SenderId: "123",
|
||||
},
|
||||
setup: () => callGatewayMock.mockRejectedValue(new Error("unknown or expired approval id")),
|
||||
setup: () =>
|
||||
callGatewayMock.mockRejectedValue(
|
||||
gatewayError("unknown or expired approval id", "APPROVAL_NOT_FOUND"),
|
||||
),
|
||||
expectedText: "unknown or expired approval id",
|
||||
expectGatewayCalls: 1,
|
||||
expectGatewayCalls: 2,
|
||||
},
|
||||
{
|
||||
name: "telegram approvals disabled",
|
||||
|
|
@ -481,6 +484,56 @@ describe("/approve command", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("rejects Telegram plugin-prefixed IDs when no approver policy is configured", async () => {
|
||||
const cfg = createTelegramApproveCfg(null);
|
||||
const params = buildParams("/approve plugin:abc123 allow-once", cfg, {
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
SenderId: "123",
|
||||
});
|
||||
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("not authorized to approve plugin requests");
|
||||
expect(callGatewayMock).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("enforces Telegram approver policy for plugin-prefixed IDs when configured", async () => {
|
||||
const cfg = createTelegramApproveCfg({ enabled: false, approvers: ["999"], target: "dm" });
|
||||
const params = buildParams("/approve plugin:abc123 allow-once", cfg, {
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
SenderId: "123",
|
||||
});
|
||||
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("not authorized to approve plugin requests");
|
||||
expect(callGatewayMock).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("allows Telegram plugin-prefixed IDs for configured approvers even when exec approvals are disabled", async () => {
|
||||
const cfg = createTelegramApproveCfg({ enabled: false, approvers: ["123"], target: "dm" });
|
||||
const params = buildParams("/approve plugin:abc123 allow-once", cfg, {
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
SenderId: "123",
|
||||
});
|
||||
|
||||
callGatewayMock.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Approval allow-once submitted");
|
||||
expect(callGatewayMock).toHaveBeenCalledTimes(1);
|
||||
expect(callGatewayMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "plugin.approval.resolve",
|
||||
params: { id: "plugin:abc123", decision: "allow-once" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("enforces gateway approval scopes", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
|
|
@ -493,12 +546,12 @@ describe("/approve command", () => {
|
|||
},
|
||||
{
|
||||
scopes: ["operator.approvals"],
|
||||
expectedText: "Exec approval allow-once submitted",
|
||||
expectedText: "Approval allow-once submitted",
|
||||
expectedGatewayCalls: 1,
|
||||
},
|
||||
{
|
||||
scopes: ["operator.admin"],
|
||||
expectedText: "Exec approval allow-once submitted",
|
||||
expectedText: "Approval allow-once submitted",
|
||||
expectedGatewayCalls: 1,
|
||||
},
|
||||
] as const;
|
||||
|
|
@ -527,6 +580,205 @@ describe("/approve command", () => {
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
function gatewayError(message: string, gatewayCode: string, opts?: { details?: unknown }): Error {
|
||||
const err = new Error(message) as Error & { gatewayCode?: string; details?: unknown };
|
||||
err.name = "GatewayClientRequestError";
|
||||
err.gatewayCode = gatewayCode;
|
||||
if (opts && "details" in opts) {
|
||||
err.details = opts.details;
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
it("falls back to plugin.approval.resolve when exec approval id is unknown", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams("/approve plugin-123 allow-once", cfg, { SenderId: "123" });
|
||||
|
||||
callGatewayMock
|
||||
.mockRejectedValueOnce(gatewayError("unknown or expired approval id", "APPROVAL_NOT_FOUND"))
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Approval allow-once submitted");
|
||||
expect(callGatewayMock).toHaveBeenCalledTimes(2);
|
||||
expect(callGatewayMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ method: "exec.approval.resolve" }),
|
||||
);
|
||||
expect(callGatewayMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
method: "plugin.approval.resolve",
|
||||
params: { id: "plugin-123", decision: "allow-once" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to plugin.approval.resolve for INVALID_REQUEST with approval-not-found details", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams("/approve plugin-123 allow-once", cfg, { SenderId: "123" });
|
||||
|
||||
callGatewayMock
|
||||
.mockRejectedValueOnce(
|
||||
gatewayError("unknown or expired approval id", "INVALID_REQUEST", {
|
||||
details: { reason: "APPROVAL_NOT_FOUND" },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Approval allow-once submitted");
|
||||
expect(callGatewayMock).toHaveBeenCalledTimes(2);
|
||||
expect(callGatewayMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ method: "exec.approval.resolve" }),
|
||||
);
|
||||
expect(callGatewayMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
method: "plugin.approval.resolve",
|
||||
params: { id: "plugin-123", decision: "allow-once" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to plugin.approval.resolve for legacy message-only not-found errors", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams("/approve plugin-123 allow-once", cfg, { SenderId: "123" });
|
||||
|
||||
callGatewayMock
|
||||
.mockRejectedValueOnce(
|
||||
gatewayError("unknown or expired approval id", "INVALID_REQUEST", {
|
||||
details: { reason: "SOMETHING_ELSE" },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Approval allow-once submitted");
|
||||
expect(callGatewayMock).toHaveBeenCalledTimes(2);
|
||||
expect(callGatewayMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ method: "exec.approval.resolve" }),
|
||||
);
|
||||
expect(callGatewayMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
method: "plugin.approval.resolve",
|
||||
params: { id: "plugin-123", decision: "allow-once" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("supports old and new unknown-id gateway envelopes across sequential approvals", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as OpenClawConfig;
|
||||
const cases = [
|
||||
{
|
||||
id: "plugin-old-1",
|
||||
err: gatewayError("unknown or expired approval id", "APPROVAL_NOT_FOUND"),
|
||||
},
|
||||
{
|
||||
id: "plugin-new-2",
|
||||
err: gatewayError("unknown or expired approval id", "INVALID_REQUEST", {
|
||||
details: { reason: "APPROVAL_NOT_FOUND" },
|
||||
}),
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
callGatewayMock.mockReset();
|
||||
callGatewayMock.mockRejectedValueOnce(testCase.err).mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const params = buildParams(`/approve ${testCase.id} allow-once`, cfg, { SenderId: "123" });
|
||||
const result = await handleCommands(params);
|
||||
|
||||
expect(result.shouldContinue, testCase.id).toBe(false);
|
||||
expect(result.reply?.text, testCase.id).toContain("Approval allow-once submitted");
|
||||
expect(callGatewayMock, testCase.id).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
method: "exec.approval.resolve",
|
||||
params: { id: testCase.id, decision: "allow-once" },
|
||||
}),
|
||||
);
|
||||
expect(callGatewayMock, testCase.id).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
method: "plugin.approval.resolve",
|
||||
params: { id: testCase.id, decision: "allow-once" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces plugin approval error when both exec and plugin resolve fail", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams("/approve bad-id deny", cfg, { SenderId: "123" });
|
||||
|
||||
callGatewayMock
|
||||
.mockRejectedValueOnce(gatewayError("unknown or expired approval id", "APPROVAL_NOT_FOUND"))
|
||||
.mockRejectedValueOnce(gatewayError("unknown or expired approval id", "APPROVAL_NOT_FOUND"));
|
||||
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Failed to submit approval");
|
||||
expect(callGatewayMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("routes plugin-prefixed IDs directly to plugin.approval.resolve", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams("/approve plugin:abc-123 allow-once", cfg, { SenderId: "123" });
|
||||
|
||||
callGatewayMock.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Approval allow-once submitted");
|
||||
expect(callGatewayMock).toHaveBeenCalledTimes(1);
|
||||
expect(callGatewayMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "plugin.approval.resolve",
|
||||
params: { id: "plugin:abc-123", decision: "allow-once" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fall back to plugin resolve for non-id errors", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams("/approve abc allow-once", cfg, { SenderId: "123" });
|
||||
|
||||
callGatewayMock.mockRejectedValueOnce(new Error("gateway connection refused"));
|
||||
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("gateway connection refused");
|
||||
expect(callGatewayMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/compact command", () => {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ import type { GroupToolPolicyConfig } from "../../config/types.tools.js";
|
|||
import type { ExecApprovalRequest, ExecApprovalResolved } from "../../infra/exec-approvals.js";
|
||||
import type { OutboundDeliveryResult, OutboundSendDeps } from "../../infra/outbound/deliver.js";
|
||||
import type { OutboundIdentity } from "../../infra/outbound/identity.js";
|
||||
import type {
|
||||
PluginApprovalRequest,
|
||||
PluginApprovalResolved,
|
||||
} from "../../infra/plugin-approvals.js";
|
||||
import type { PluginRuntime } from "../../plugins/runtime/types.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import type { ConfigWriteTarget } from "./config-writes.js";
|
||||
|
|
@ -493,6 +497,17 @@ export type ChannelExecApprovalAdapter = {
|
|||
target: ChannelExecApprovalForwardTarget;
|
||||
payload: ReplyPayload;
|
||||
}) => Promise<void> | void;
|
||||
buildPluginPendingPayload?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
request: PluginApprovalRequest;
|
||||
target: ChannelExecApprovalForwardTarget;
|
||||
nowMs: number;
|
||||
}) => ReplyPayload | null;
|
||||
buildPluginResolvedPayload?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
resolved: PluginApprovalResolved;
|
||||
target: ChannelExecApprovalForwardTarget;
|
||||
}) => ReplyPayload | null;
|
||||
};
|
||||
|
||||
export type ChannelAllowlistAdapter = {
|
||||
|
|
|
|||
|
|
@ -8388,6 +8388,74 @@ export const GENERATED_BASE_CONFIG_SCHEMA = {
|
|||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
plugin: {
|
||||
type: "object",
|
||||
properties: {
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
},
|
||||
mode: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
const: "session",
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
const: "targets",
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
const: "both",
|
||||
},
|
||||
],
|
||||
},
|
||||
agentFilter: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
sessionFilter: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
targets: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
channel: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
},
|
||||
to: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
},
|
||||
accountId: {
|
||||
type: "string",
|
||||
},
|
||||
threadId: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
required: ["channel", "to"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
|
|
@ -12532,7 +12600,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA = {
|
|||
},
|
||||
approvals: {
|
||||
label: "Approvals",
|
||||
help: "Approval routing controls for forwarding exec approval requests to chat destinations outside the originating session. Keep this disabled unless operators need explicit out-of-band approval visibility.",
|
||||
help: "Approval routing controls for forwarding exec and plugin approval requests to chat destinations outside the originating session. Keep these disabled unless operators need explicit out-of-band approval visibility.",
|
||||
tags: ["advanced"],
|
||||
},
|
||||
"approvals.exec": {
|
||||
|
|
@ -12585,6 +12653,56 @@ export const GENERATED_BASE_CONFIG_SCHEMA = {
|
|||
help: "Optional thread/topic target for channels that support threaded delivery of forwarded approvals. Use this to keep approval traffic contained in operational threads instead of main channels.",
|
||||
tags: ["advanced"],
|
||||
},
|
||||
"approvals.plugin": {
|
||||
label: "Plugin Approval Forwarding",
|
||||
help: "Groups plugin-approval forwarding behavior including enablement, routing mode, filters, and explicit targets. Independent of exec approval forwarding. Configure here when plugin approval prompts must reach operational channels.",
|
||||
tags: ["advanced"],
|
||||
},
|
||||
"approvals.plugin.enabled": {
|
||||
label: "Forward Plugin Approvals",
|
||||
help: "Enables forwarding of plugin approval requests to configured delivery destinations (default: false). Independent of approvals.exec.enabled.",
|
||||
tags: ["advanced"],
|
||||
},
|
||||
"approvals.plugin.mode": {
|
||||
label: "Plugin Approval Forwarding Mode",
|
||||
help: 'Controls where plugin approval prompts are sent: "session" uses origin chat, "targets" uses configured targets, and "both" sends to both paths.',
|
||||
tags: ["advanced"],
|
||||
},
|
||||
"approvals.plugin.agentFilter": {
|
||||
label: "Plugin Approval Agent Filter",
|
||||
help: 'Optional allowlist of agent IDs eligible for forwarded plugin approvals, for example `["primary", "ops-agent"]`. Use this to limit forwarding blast radius.',
|
||||
tags: ["advanced"],
|
||||
},
|
||||
"approvals.plugin.sessionFilter": {
|
||||
label: "Plugin Approval Session Filter",
|
||||
help: 'Optional session-key filters matched as substring or regex-style patterns, for example `["discord:", "^agent:ops:"]`. Use narrow patterns so only intended approval contexts are forwarded.',
|
||||
tags: ["storage"],
|
||||
},
|
||||
"approvals.plugin.targets": {
|
||||
label: "Plugin Approval Forwarding Targets",
|
||||
help: "Explicit delivery targets used when plugin approval forwarding mode includes targets, each with channel and destination details.",
|
||||
tags: ["advanced"],
|
||||
},
|
||||
"approvals.plugin.targets[].channel": {
|
||||
label: "Plugin Approval Target Channel",
|
||||
help: "Channel/provider ID used for forwarded plugin approval delivery, such as discord, slack, or a plugin channel id.",
|
||||
tags: ["advanced"],
|
||||
},
|
||||
"approvals.plugin.targets[].to": {
|
||||
label: "Plugin Approval Target Destination",
|
||||
help: "Destination identifier inside the target channel (channel ID, user ID, or thread root depending on provider).",
|
||||
tags: ["advanced"],
|
||||
},
|
||||
"approvals.plugin.targets[].accountId": {
|
||||
label: "Plugin Approval Target Account ID",
|
||||
help: "Optional account selector for multi-account channel setups when plugin approvals must route through a specific account context.",
|
||||
tags: ["advanced"],
|
||||
},
|
||||
"approvals.plugin.targets[].threadId": {
|
||||
label: "Plugin Approval Target Thread ID",
|
||||
help: "Optional thread/topic target for channels that support threaded delivery of forwarded plugin approvals.",
|
||||
tags: ["advanced"],
|
||||
},
|
||||
"tools.message.allowCrossContextSend": {
|
||||
label: "Allow Cross-Context Messaging",
|
||||
help: "Legacy override: allow cross-context sends across all providers.",
|
||||
|
|
|
|||
|
|
@ -630,7 +630,7 @@ export const FIELD_HELP: Record<string, string> = {
|
|||
"skills.load.watchDebounceMs":
|
||||
"Debounce window in milliseconds for coalescing rapid skill file changes before reload logic runs. Increase to reduce reload churn on frequent writes, or lower for faster edit feedback.",
|
||||
approvals:
|
||||
"Approval routing controls for forwarding exec approval requests to chat destinations outside the originating session. Keep this disabled unless operators need explicit out-of-band approval visibility.",
|
||||
"Approval routing controls for forwarding exec and plugin approval requests to chat destinations outside the originating session. Keep these disabled unless operators need explicit out-of-band approval visibility.",
|
||||
"approvals.exec":
|
||||
"Groups exec-approval forwarding behavior including enablement, routing mode, filters, and explicit targets. Configure here when approval prompts must reach operational channels instead of only the origin thread.",
|
||||
"approvals.exec.enabled":
|
||||
|
|
@ -651,6 +651,26 @@ export const FIELD_HELP: Record<string, string> = {
|
|||
"Optional account selector for multi-account channel setups when approvals must route through a specific account context. Use this only when the target channel has multiple configured identities.",
|
||||
"approvals.exec.targets[].threadId":
|
||||
"Optional thread/topic target for channels that support threaded delivery of forwarded approvals. Use this to keep approval traffic contained in operational threads instead of main channels.",
|
||||
"approvals.plugin":
|
||||
"Groups plugin-approval forwarding behavior including enablement, routing mode, filters, and explicit targets. Independent of exec approval forwarding. Configure here when plugin approval prompts must reach operational channels.",
|
||||
"approvals.plugin.enabled":
|
||||
"Enables forwarding of plugin approval requests to configured delivery destinations (default: false). Independent of approvals.exec.enabled.",
|
||||
"approvals.plugin.mode":
|
||||
'Controls where plugin approval prompts are sent: "session" uses origin chat, "targets" uses configured targets, and "both" sends to both paths.',
|
||||
"approvals.plugin.agentFilter":
|
||||
'Optional allowlist of agent IDs eligible for forwarded plugin approvals, for example `["primary", "ops-agent"]`. Use this to limit forwarding blast radius.',
|
||||
"approvals.plugin.sessionFilter":
|
||||
'Optional session-key filters matched as substring or regex-style patterns, for example `["discord:", "^agent:ops:"]`. Use narrow patterns so only intended approval contexts are forwarded.',
|
||||
"approvals.plugin.targets":
|
||||
"Explicit delivery targets used when plugin approval forwarding mode includes targets, each with channel and destination details.",
|
||||
"approvals.plugin.targets[].channel":
|
||||
"Channel/provider ID used for forwarded plugin approval delivery, such as discord, slack, or a plugin channel id.",
|
||||
"approvals.plugin.targets[].to":
|
||||
"Destination identifier inside the target channel (channel ID, user ID, or thread root depending on provider).",
|
||||
"approvals.plugin.targets[].accountId":
|
||||
"Optional account selector for multi-account channel setups when plugin approvals must route through a specific account context.",
|
||||
"approvals.plugin.targets[].threadId":
|
||||
"Optional thread/topic target for channels that support threaded delivery of forwarded plugin approvals.",
|
||||
"tools.fs.workspaceOnly":
|
||||
"Restrict filesystem tools (read/write/edit/apply_patch) to the workspace directory (default: false).",
|
||||
"tools.sessions.visibility":
|
||||
|
|
|
|||
|
|
@ -210,6 +210,16 @@ export const FIELD_LABELS: Record<string, string> = {
|
|||
"approvals.exec.targets[].to": "Approval Target Destination",
|
||||
"approvals.exec.targets[].accountId": "Approval Target Account ID",
|
||||
"approvals.exec.targets[].threadId": "Approval Target Thread ID",
|
||||
"approvals.plugin": "Plugin Approval Forwarding",
|
||||
"approvals.plugin.enabled": "Forward Plugin Approvals",
|
||||
"approvals.plugin.mode": "Plugin Approval Forwarding Mode",
|
||||
"approvals.plugin.agentFilter": "Plugin Approval Agent Filter",
|
||||
"approvals.plugin.sessionFilter": "Plugin Approval Session Filter",
|
||||
"approvals.plugin.targets": "Plugin Approval Forwarding Targets",
|
||||
"approvals.plugin.targets[].channel": "Plugin Approval Target Channel",
|
||||
"approvals.plugin.targets[].to": "Plugin Approval Target Destination",
|
||||
"approvals.plugin.targets[].accountId": "Plugin Approval Target Account ID",
|
||||
"approvals.plugin.targets[].threadId": "Plugin Approval Target Thread ID",
|
||||
"tools.message.allowCrossContextSend": "Allow Cross-Context Messaging",
|
||||
"tools.message.crossContext.allowWithinProvider": "Allow Cross-Context (Same Provider)",
|
||||
"tools.message.crossContext.allowAcrossProviders": "Allow Cross-Context (Across Providers)",
|
||||
|
|
|
|||
|
|
@ -26,4 +26,5 @@ export type ExecApprovalForwardingConfig = {
|
|||
|
||||
export type ApprovalsConfig = {
|
||||
exec?: ExecApprovalForwardingConfig;
|
||||
plugin?: ExecApprovalForwardingConfig;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ const ExecApprovalForwardingSchema = z
|
|||
export const ApprovalsSchema = z
|
||||
.object({
|
||||
exec: ExecApprovalForwardingSchema,
|
||||
plugin: ExecApprovalForwardingSchema,
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ const RESOLVED_ENTRY_GRACE_MS = 15_000;
|
|||
|
||||
export type ExecApprovalRequestPayload = InfraExecApprovalRequestPayload;
|
||||
|
||||
export type ExecApprovalRecord = {
|
||||
export type ExecApprovalRecord<TPayload = ExecApprovalRequestPayload> = {
|
||||
id: string;
|
||||
request: ExecApprovalRequestPayload;
|
||||
request: TPayload;
|
||||
createdAtMs: number;
|
||||
expiresAtMs: number;
|
||||
// Caller metadata (best-effort). Used to prevent other clients from replaying an approval id.
|
||||
|
|
@ -23,8 +23,8 @@ export type ExecApprovalRecord = {
|
|||
resolvedBy?: string | null;
|
||||
};
|
||||
|
||||
type PendingEntry = {
|
||||
record: ExecApprovalRecord;
|
||||
type PendingEntry<TPayload = ExecApprovalRequestPayload> = {
|
||||
record: ExecApprovalRecord<TPayload>;
|
||||
resolve: (decision: ExecApprovalDecision | null) => void;
|
||||
reject: (err: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
|
|
@ -36,17 +36,13 @@ export type ExecApprovalIdLookupResult =
|
|||
| { kind: "ambiguous"; ids: string[] }
|
||||
| { kind: "none" };
|
||||
|
||||
export class ExecApprovalManager {
|
||||
private pending = new Map<string, PendingEntry>();
|
||||
export class ExecApprovalManager<TPayload = ExecApprovalRequestPayload> {
|
||||
private pending = new Map<string, PendingEntry<TPayload>>();
|
||||
|
||||
create(
|
||||
request: ExecApprovalRequestPayload,
|
||||
timeoutMs: number,
|
||||
id?: string | null,
|
||||
): ExecApprovalRecord {
|
||||
create(request: TPayload, timeoutMs: number, id?: string | null): ExecApprovalRecord<TPayload> {
|
||||
const now = Date.now();
|
||||
const resolvedId = id && id.trim().length > 0 ? id.trim() : randomUUID();
|
||||
const record: ExecApprovalRecord = {
|
||||
const record: ExecApprovalRecord<TPayload> = {
|
||||
id: resolvedId,
|
||||
request,
|
||||
createdAtMs: now,
|
||||
|
|
@ -60,7 +56,10 @@ export class ExecApprovalManager {
|
|||
* This separates registration (synchronous) from waiting (async), allowing callers to
|
||||
* confirm registration before the decision is made.
|
||||
*/
|
||||
register(record: ExecApprovalRecord, timeoutMs: number): Promise<ExecApprovalDecision | null> {
|
||||
register(
|
||||
record: ExecApprovalRecord<TPayload>,
|
||||
timeoutMs: number,
|
||||
): Promise<ExecApprovalDecision | null> {
|
||||
const existing = this.pending.get(record.id);
|
||||
if (existing) {
|
||||
// Idempotent: return existing promise if still pending
|
||||
|
|
@ -77,7 +76,7 @@ export class ExecApprovalManager {
|
|||
rejectPromise = reject;
|
||||
});
|
||||
// Create entry first so we can capture it in the closure (not re-fetch from map)
|
||||
const entry: PendingEntry = {
|
||||
const entry: PendingEntry<TPayload> = {
|
||||
record,
|
||||
resolve: resolvePromise!,
|
||||
reject: rejectPromise!,
|
||||
|
|
@ -95,7 +94,7 @@ export class ExecApprovalManager {
|
|||
* @deprecated Use register() instead for explicit separation of registration and waiting.
|
||||
*/
|
||||
async waitForDecision(
|
||||
record: ExecApprovalRecord,
|
||||
record: ExecApprovalRecord<TPayload>,
|
||||
timeoutMs: number,
|
||||
): Promise<ExecApprovalDecision | null> {
|
||||
return this.register(record, timeoutMs);
|
||||
|
|
@ -147,7 +146,7 @@ export class ExecApprovalManager {
|
|||
return true;
|
||||
}
|
||||
|
||||
getSnapshot(recordId: string): ExecApprovalRecord | null {
|
||||
getSnapshot(recordId: string): ExecApprovalRecord<TPayload> | null {
|
||||
const entry = this.pending.get(recordId);
|
||||
return entry?.record ?? null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,19 @@ describe("operator scope authorization", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it.each(["plugin.approval.request", "plugin.approval.waitDecision", "plugin.approval.resolve"])(
|
||||
"requires approvals scope for %s",
|
||||
(method) => {
|
||||
expect(authorizeOperatorScopesForMethod(method, ["operator.write"])).toEqual({
|
||||
allowed: false,
|
||||
missingScope: "operator.approvals",
|
||||
});
|
||||
expect(authorizeOperatorScopesForMethod(method, ["operator.approvals"])).toEqual({
|
||||
allowed: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("requires admin for unknown methods", () => {
|
||||
expect(authorizeOperatorScopesForMethod("unknown.method", ["operator.read"])).toEqual({
|
||||
allowed: false,
|
||||
|
|
@ -83,6 +96,21 @@ describe("operator scope authorization", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("plugin approval method registration", () => {
|
||||
it("lists all plugin approval methods", () => {
|
||||
const methods = listGatewayMethods();
|
||||
expect(methods).toContain("plugin.approval.request");
|
||||
expect(methods).toContain("plugin.approval.waitDecision");
|
||||
expect(methods).toContain("plugin.approval.resolve");
|
||||
});
|
||||
|
||||
it("classifies plugin approval methods", () => {
|
||||
expect(isGatewayMethodClassified("plugin.approval.request")).toBe(true);
|
||||
expect(isGatewayMethodClassified("plugin.approval.waitDecision")).toBe(true);
|
||||
expect(isGatewayMethodClassified("plugin.approval.resolve")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("core gateway method classification", () => {
|
||||
it("treats node-role methods as classified even without operator scopes", () => {
|
||||
expect(isGatewayMethodClassified("node.pending.drain")).toBe(true);
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
|
|||
"exec.approval.request",
|
||||
"exec.approval.waitDecision",
|
||||
"exec.approval.resolve",
|
||||
"plugin.approval.request",
|
||||
"plugin.approval.waitDecision",
|
||||
"plugin.approval.resolve",
|
||||
],
|
||||
[PAIRING_SCOPE]: [
|
||||
"node.pair.request",
|
||||
|
|
|
|||
|
|
@ -124,6 +124,10 @@ import {
|
|||
ExecApprovalRequestParamsSchema,
|
||||
type ExecApprovalResolveParams,
|
||||
ExecApprovalResolveParamsSchema,
|
||||
type PluginApprovalRequestParams,
|
||||
PluginApprovalRequestParamsSchema,
|
||||
type PluginApprovalResolveParams,
|
||||
PluginApprovalResolveParamsSchema,
|
||||
ErrorCodes,
|
||||
type ErrorShape,
|
||||
ErrorShapeSchema,
|
||||
|
|
@ -437,6 +441,12 @@ export const validateExecApprovalRequestParams = ajv.compile<ExecApprovalRequest
|
|||
export const validateExecApprovalResolveParams = ajv.compile<ExecApprovalResolveParams>(
|
||||
ExecApprovalResolveParamsSchema,
|
||||
);
|
||||
export const validatePluginApprovalRequestParams = ajv.compile<PluginApprovalRequestParams>(
|
||||
PluginApprovalRequestParamsSchema,
|
||||
);
|
||||
export const validatePluginApprovalResolveParams = ajv.compile<PluginApprovalResolveParams>(
|
||||
PluginApprovalResolveParamsSchema,
|
||||
);
|
||||
export const validateExecApprovalsNodeGetParams = ajv.compile<ExecApprovalsNodeGetParams>(
|
||||
ExecApprovalsNodeGetParamsSchema,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -15,4 +15,5 @@ export * from "./schema/secrets.js";
|
|||
export * from "./schema/sessions.js";
|
||||
export * from "./schema/snapshot.js";
|
||||
export * from "./schema/types.js";
|
||||
export * from "./schema/plugin-approvals.js";
|
||||
export * from "./schema/wizard.js";
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export const ErrorCodes = {
|
|||
NOT_PAIRED: "NOT_PAIRED",
|
||||
AGENT_TIMEOUT: "AGENT_TIMEOUT",
|
||||
INVALID_REQUEST: "INVALID_REQUEST",
|
||||
APPROVAL_NOT_FOUND: "APPROVAL_NOT_FOUND",
|
||||
UNAVAILABLE: "UNAVAILABLE",
|
||||
} as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
import { Type } from "@sinclair/typebox";
|
||||
import {
|
||||
MAX_PLUGIN_APPROVAL_TIMEOUT_MS,
|
||||
PLUGIN_APPROVAL_DESCRIPTION_MAX_LENGTH,
|
||||
PLUGIN_APPROVAL_TITLE_MAX_LENGTH,
|
||||
} from "../../../infra/plugin-approvals.js";
|
||||
import { NonEmptyString } from "./primitives.js";
|
||||
|
||||
export const PluginApprovalRequestParamsSchema = Type.Object(
|
||||
{
|
||||
pluginId: Type.Optional(NonEmptyString),
|
||||
title: Type.String({ minLength: 1, maxLength: PLUGIN_APPROVAL_TITLE_MAX_LENGTH }),
|
||||
description: Type.String({ minLength: 1, maxLength: PLUGIN_APPROVAL_DESCRIPTION_MAX_LENGTH }),
|
||||
severity: Type.Optional(Type.String({ enum: ["info", "warning", "critical"] })),
|
||||
toolName: Type.Optional(Type.String()),
|
||||
toolCallId: Type.Optional(Type.String()),
|
||||
agentId: Type.Optional(Type.String()),
|
||||
sessionKey: Type.Optional(Type.String()),
|
||||
turnSourceChannel: Type.Optional(Type.String()),
|
||||
turnSourceTo: Type.Optional(Type.String()),
|
||||
turnSourceAccountId: Type.Optional(Type.String()),
|
||||
turnSourceThreadId: Type.Optional(Type.Union([Type.String(), Type.Number()])),
|
||||
timeoutMs: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_PLUGIN_APPROVAL_TIMEOUT_MS })),
|
||||
twoPhase: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const PluginApprovalResolveParamsSchema = Type.Object(
|
||||
{
|
||||
id: NonEmptyString,
|
||||
decision: NonEmptyString,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
|
@ -136,6 +136,10 @@ import {
|
|||
NodePairVerifyParamsSchema,
|
||||
NodeRenameParamsSchema,
|
||||
} from "./nodes.js";
|
||||
import {
|
||||
PluginApprovalRequestParamsSchema,
|
||||
PluginApprovalResolveParamsSchema,
|
||||
} from "./plugin-approvals.js";
|
||||
import { PushTestParamsSchema, PushTestResultSchema } from "./push.js";
|
||||
import {
|
||||
SecretsReloadParamsSchema,
|
||||
|
|
@ -302,6 +306,8 @@ export const ProtocolSchemas = {
|
|||
ExecApprovalsSnapshot: ExecApprovalsSnapshotSchema,
|
||||
ExecApprovalRequestParams: ExecApprovalRequestParamsSchema,
|
||||
ExecApprovalResolveParams: ExecApprovalResolveParamsSchema,
|
||||
PluginApprovalRequestParams: PluginApprovalRequestParamsSchema,
|
||||
PluginApprovalResolveParams: PluginApprovalResolveParamsSchema,
|
||||
DevicePairListParams: DevicePairListParamsSchema,
|
||||
DevicePairApproveParams: DevicePairApproveParamsSchema,
|
||||
DevicePairRejectParams: DevicePairRejectParamsSchema,
|
||||
|
|
|
|||
|
|
@ -128,6 +128,8 @@ export type ExecApprovalsNodeSetParams = SchemaType<"ExecApprovalsNodeSetParams"
|
|||
export type ExecApprovalsSnapshot = SchemaType<"ExecApprovalsSnapshot">;
|
||||
export type ExecApprovalRequestParams = SchemaType<"ExecApprovalRequestParams">;
|
||||
export type ExecApprovalResolveParams = SchemaType<"ExecApprovalResolveParams">;
|
||||
export type PluginApprovalRequestParams = SchemaType<"PluginApprovalRequestParams">;
|
||||
export type PluginApprovalResolveParams = SchemaType<"PluginApprovalResolveParams">;
|
||||
export type DevicePairListParams = SchemaType<"DevicePairListParams">;
|
||||
export type DevicePairApproveParams = SchemaType<"DevicePairApproveParams">;
|
||||
export type DevicePairRejectParams = SchemaType<"DevicePairRejectParams">;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import { logWs, shouldLogWs, summarizeAgentEventForWsLog } from "./ws-log.js";
|
|||
const EVENT_SCOPE_GUARDS: Record<string, string[]> = {
|
||||
"exec.approval.requested": [APPROVALS_SCOPE],
|
||||
"exec.approval.resolved": [APPROVALS_SCOPE],
|
||||
"plugin.approval.requested": [APPROVALS_SCOPE],
|
||||
"plugin.approval.resolved": [APPROVALS_SCOPE],
|
||||
"device.pair.requested": [PAIRING_SCOPE],
|
||||
"device.pair.resolved": [PAIRING_SCOPE],
|
||||
"node.pair.requested": [PAIRING_SCOPE],
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ const BASE_METHODS = [
|
|||
"exec.approval.request",
|
||||
"exec.approval.waitDecision",
|
||||
"exec.approval.resolve",
|
||||
"plugin.approval.request",
|
||||
"plugin.approval.waitDecision",
|
||||
"plugin.approval.resolve",
|
||||
"wizard.start",
|
||||
"wizard.next",
|
||||
"wizard.cancel",
|
||||
|
|
@ -140,5 +143,7 @@ export const GATEWAY_EVENTS = [
|
|||
"voicewake.changed",
|
||||
"exec.approval.requested",
|
||||
"exec.approval.resolved",
|
||||
"plugin.approval.requested",
|
||||
"plugin.approval.resolved",
|
||||
GATEWAY_EVENT_UPDATE_AVAILABLE,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ import {
|
|||
} from "../protocol/index.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
const APPROVAL_NOT_FOUND_DETAILS = {
|
||||
reason: ErrorCodes.APPROVAL_NOT_FOUND,
|
||||
} as const;
|
||||
|
||||
export function createExecApprovalHandlers(
|
||||
manager: ExecApprovalManager,
|
||||
opts?: { forwarder?: ExecApprovalForwarder },
|
||||
|
|
@ -183,7 +187,7 @@ export function createExecApprovalHandlers(
|
|||
},
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
const hasExecApprovalClients = context.hasExecApprovalClients?.() ?? false;
|
||||
const hasExecApprovalClients = context.hasExecApprovalClients?.(client?.connId) ?? false;
|
||||
let forwarded = false;
|
||||
if (opts?.forwarder) {
|
||||
try {
|
||||
|
|
@ -297,7 +301,9 @@ export function createExecApprovalHandlers(
|
|||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id"),
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id", {
|
||||
details: APPROVAL_NOT_FOUND_DETAILS,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -322,7 +328,9 @@ export function createExecApprovalHandlers(
|
|||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id"),
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id", {
|
||||
details: APPROVAL_NOT_FOUND_DETAILS,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,431 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginApprovalRequestPayload } from "../../infra/plugin-approvals.js";
|
||||
import { ExecApprovalManager } from "../exec-approval-manager.js";
|
||||
import { createPluginApprovalHandlers } from "./plugin-approval.js";
|
||||
import type { GatewayRequestHandlerOptions } from "./types.js";
|
||||
|
||||
function createManager() {
|
||||
return new ExecApprovalManager<PluginApprovalRequestPayload>();
|
||||
}
|
||||
|
||||
function createMockOptions(
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
overrides?: Partial<GatewayRequestHandlerOptions>,
|
||||
): GatewayRequestHandlerOptions {
|
||||
return {
|
||||
req: { method, params, id: "req-1" },
|
||||
params,
|
||||
client: {
|
||||
connect: {
|
||||
client: { id: "test-client", displayName: "Test Client" },
|
||||
},
|
||||
},
|
||||
isWebchatConnect: () => false,
|
||||
respond: vi.fn(),
|
||||
context: {
|
||||
broadcast: vi.fn(),
|
||||
logGateway: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
|
||||
hasExecApprovalClients: () => true,
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as GatewayRequestHandlerOptions;
|
||||
}
|
||||
|
||||
describe("createPluginApprovalHandlers", () => {
|
||||
let manager: ExecApprovalManager<PluginApprovalRequestPayload>;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = createManager();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns handlers for all three plugin approval methods", () => {
|
||||
const handlers = createPluginApprovalHandlers(manager);
|
||||
expect(handlers).toHaveProperty("plugin.approval.request");
|
||||
expect(handlers).toHaveProperty("plugin.approval.waitDecision");
|
||||
expect(handlers).toHaveProperty("plugin.approval.resolve");
|
||||
expect(typeof handlers["plugin.approval.request"]).toBe("function");
|
||||
expect(typeof handlers["plugin.approval.waitDecision"]).toBe("function");
|
||||
expect(typeof handlers["plugin.approval.resolve"]).toBe("function");
|
||||
});
|
||||
|
||||
describe("plugin.approval.request", () => {
|
||||
it("rejects invalid params", async () => {
|
||||
const handlers = createPluginApprovalHandlers(manager);
|
||||
const opts = createMockOptions("plugin.approval.request", {});
|
||||
await handlers["plugin.approval.request"](opts);
|
||||
expect(opts.respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
code: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates and registers approval with twoPhase", async () => {
|
||||
const handlers = createPluginApprovalHandlers(manager);
|
||||
const respond = vi.fn();
|
||||
const opts = createMockOptions(
|
||||
"plugin.approval.request",
|
||||
{
|
||||
title: "Sensitive action",
|
||||
description: "This tool modifies production data",
|
||||
severity: "warning",
|
||||
twoPhase: true,
|
||||
},
|
||||
{ respond },
|
||||
);
|
||||
|
||||
// Don't await — the handler blocks waiting for the decision.
|
||||
// Instead, let it run and resolve the approval after the accepted response.
|
||||
const handlerPromise = handlers["plugin.approval.request"](opts);
|
||||
|
||||
// Wait for the twoPhase "accepted" response
|
||||
await vi.waitFor(() => {
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({ status: "accepted", id: expect.any(String) }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
expect(opts.context.broadcast).toHaveBeenCalledWith(
|
||||
"plugin.approval.requested",
|
||||
expect.objectContaining({ id: expect.any(String) }),
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
|
||||
// Resolve the approval so the handler can complete
|
||||
const acceptedCall = respond.mock.calls.find(
|
||||
(c) => (c[1] as Record<string, unknown>)?.status === "accepted",
|
||||
);
|
||||
const approvalId = (acceptedCall?.[1] as Record<string, unknown>)?.id as string;
|
||||
manager.resolve(approvalId, "allow-once");
|
||||
|
||||
await handlerPromise;
|
||||
|
||||
// Final response with decision
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({ id: approvalId, decision: "allow-once" }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("expires immediately when no approval route", async () => {
|
||||
const handlers = createPluginApprovalHandlers(manager);
|
||||
const opts = createMockOptions(
|
||||
"plugin.approval.request",
|
||||
{
|
||||
title: "Sensitive action",
|
||||
description: "Desc",
|
||||
},
|
||||
{
|
||||
context: {
|
||||
broadcast: vi.fn(),
|
||||
logGateway: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
|
||||
hasExecApprovalClients: () => false,
|
||||
} as unknown as GatewayRequestHandlerOptions["context"],
|
||||
},
|
||||
);
|
||||
await handlers["plugin.approval.request"](opts);
|
||||
expect(opts.respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({ decision: null }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes caller connId to hasExecApprovalClients to exclude self", async () => {
|
||||
const handlers = createPluginApprovalHandlers(manager);
|
||||
const hasExecApprovalClients = vi.fn().mockReturnValue(false);
|
||||
const opts = createMockOptions(
|
||||
"plugin.approval.request",
|
||||
{ title: "T", description: "D" },
|
||||
{
|
||||
client: {
|
||||
connId: "backend-conn-42",
|
||||
connect: { client: { id: "test", displayName: "Test" } },
|
||||
} as unknown as GatewayRequestHandlerOptions["client"],
|
||||
context: {
|
||||
broadcast: vi.fn(),
|
||||
logGateway: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
|
||||
hasExecApprovalClients,
|
||||
} as unknown as GatewayRequestHandlerOptions["context"],
|
||||
},
|
||||
);
|
||||
await handlers["plugin.approval.request"](opts);
|
||||
expect(hasExecApprovalClients).toHaveBeenCalledWith("backend-conn-42");
|
||||
});
|
||||
|
||||
it("rejects invalid severity value", async () => {
|
||||
const handlers = createPluginApprovalHandlers(manager);
|
||||
const opts = createMockOptions("plugin.approval.request", {
|
||||
title: "T",
|
||||
description: "D",
|
||||
severity: "extreme",
|
||||
});
|
||||
await handlers["plugin.approval.request"](opts);
|
||||
expect(opts.respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ code: expect.any(String) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects title exceeding max length", async () => {
|
||||
const handlers = createPluginApprovalHandlers(manager);
|
||||
const opts = createMockOptions("plugin.approval.request", {
|
||||
title: "x".repeat(81),
|
||||
description: "D",
|
||||
});
|
||||
await handlers["plugin.approval.request"](opts);
|
||||
expect(opts.respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ code: expect.any(String) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects description exceeding max length", async () => {
|
||||
const handlers = createPluginApprovalHandlers(manager);
|
||||
const opts = createMockOptions("plugin.approval.request", {
|
||||
title: "T",
|
||||
description: "x".repeat(257),
|
||||
});
|
||||
await handlers["plugin.approval.request"](opts);
|
||||
expect(opts.respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ code: expect.any(String) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects timeoutMs exceeding max", async () => {
|
||||
const handlers = createPluginApprovalHandlers(manager);
|
||||
const opts = createMockOptions("plugin.approval.request", {
|
||||
title: "T",
|
||||
description: "D",
|
||||
timeoutMs: 700_000,
|
||||
});
|
||||
await handlers["plugin.approval.request"](opts);
|
||||
expect(opts.respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ code: expect.any(String) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("generates plugin-prefixed IDs", async () => {
|
||||
const handlers = createPluginApprovalHandlers(manager);
|
||||
const respond = vi.fn();
|
||||
const opts = createMockOptions(
|
||||
"plugin.approval.request",
|
||||
{ title: "T", description: "D" },
|
||||
{
|
||||
respond,
|
||||
context: {
|
||||
broadcast: vi.fn(),
|
||||
logGateway: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
|
||||
hasExecApprovalClients: () => false,
|
||||
} as unknown as GatewayRequestHandlerOptions["context"],
|
||||
},
|
||||
);
|
||||
await handlers["plugin.approval.request"](opts);
|
||||
const result = respond.mock.calls[0]?.[1] as Record<string, unknown> | undefined;
|
||||
expect(result?.id).toEqual(expect.stringMatching(/^plugin:/));
|
||||
});
|
||||
|
||||
it("passes plugin-prefixed IDs directly to manager.create", async () => {
|
||||
const handlers = createPluginApprovalHandlers(manager);
|
||||
const createSpy = vi.spyOn(manager, "create");
|
||||
const opts = createMockOptions(
|
||||
"plugin.approval.request",
|
||||
{ title: "T", description: "D" },
|
||||
{
|
||||
context: {
|
||||
broadcast: vi.fn(),
|
||||
logGateway: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
|
||||
hasExecApprovalClients: () => false,
|
||||
} as unknown as GatewayRequestHandlerOptions["context"],
|
||||
},
|
||||
);
|
||||
|
||||
await handlers["plugin.approval.request"](opts);
|
||||
|
||||
expect(createSpy).toHaveBeenCalledTimes(1);
|
||||
expect(createSpy.mock.calls[0]?.[2]).toEqual(expect.stringMatching(/^plugin:/));
|
||||
});
|
||||
|
||||
it("rejects plugin-provided id field", async () => {
|
||||
const handlers = createPluginApprovalHandlers(manager);
|
||||
const opts = createMockOptions("plugin.approval.request", {
|
||||
id: "plugin-provided-id",
|
||||
title: "T",
|
||||
description: "D",
|
||||
});
|
||||
await handlers["plugin.approval.request"](opts);
|
||||
expect(opts.respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ message: expect.stringContaining("unexpected property") }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin.approval.waitDecision", () => {
|
||||
it("rejects missing id", async () => {
|
||||
const handlers = createPluginApprovalHandlers(manager);
|
||||
const opts = createMockOptions("plugin.approval.waitDecision", {});
|
||||
await handlers["plugin.approval.waitDecision"](opts);
|
||||
expect(opts.respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ message: expect.stringContaining("id is required") }),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns not found for unknown id", async () => {
|
||||
const handlers = createPluginApprovalHandlers(manager);
|
||||
const opts = createMockOptions("plugin.approval.waitDecision", { id: "unknown" });
|
||||
await handlers["plugin.approval.waitDecision"](opts);
|
||||
expect(opts.respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ message: expect.stringContaining("expired or not found") }),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns decision when resolved", async () => {
|
||||
const handlers = createPluginApprovalHandlers(manager);
|
||||
const record = manager.create({ title: "T", description: "D" }, 60_000);
|
||||
void manager.register(record, 60_000);
|
||||
|
||||
// Resolve before waiting
|
||||
manager.resolve(record.id, "allow-once");
|
||||
|
||||
const opts = createMockOptions("plugin.approval.waitDecision", { id: record.id });
|
||||
await handlers["plugin.approval.waitDecision"](opts);
|
||||
expect(opts.respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({ id: record.id, decision: "allow-once" }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin.approval.resolve", () => {
|
||||
it("rejects invalid params", async () => {
|
||||
const handlers = createPluginApprovalHandlers(manager);
|
||||
const opts = createMockOptions("plugin.approval.resolve", {});
|
||||
await handlers["plugin.approval.resolve"](opts);
|
||||
expect(opts.respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
code: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid decision", async () => {
|
||||
const handlers = createPluginApprovalHandlers(manager);
|
||||
const record = manager.create({ title: "T", description: "D" }, 60_000);
|
||||
void manager.register(record, 60_000);
|
||||
const opts = createMockOptions("plugin.approval.resolve", {
|
||||
id: record.id,
|
||||
decision: "invalid",
|
||||
});
|
||||
await handlers["plugin.approval.resolve"](opts);
|
||||
expect(opts.respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ message: "invalid decision" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves a pending approval", async () => {
|
||||
const handlers = createPluginApprovalHandlers(manager);
|
||||
const record = manager.create({ title: "T", description: "D" }, 60_000);
|
||||
void manager.register(record, 60_000);
|
||||
|
||||
const opts = createMockOptions("plugin.approval.resolve", {
|
||||
id: record.id,
|
||||
decision: "deny",
|
||||
});
|
||||
await handlers["plugin.approval.resolve"](opts);
|
||||
expect(opts.respond).toHaveBeenCalledWith(true, { ok: true }, undefined);
|
||||
expect(opts.context.broadcast).toHaveBeenCalledWith(
|
||||
"plugin.approval.resolved",
|
||||
expect.objectContaining({ id: record.id, decision: "deny" }),
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown approval id", async () => {
|
||||
const handlers = createPluginApprovalHandlers(manager);
|
||||
const opts = createMockOptions("plugin.approval.resolve", {
|
||||
id: "nonexistent",
|
||||
decision: "allow-once",
|
||||
});
|
||||
await handlers["plugin.approval.resolve"](opts);
|
||||
expect(opts.respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
code: "INVALID_REQUEST",
|
||||
message: expect.stringContaining("unknown or expired"),
|
||||
details: expect.objectContaining({ reason: "APPROVAL_NOT_FOUND" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires exact id and rejects prefixes", async () => {
|
||||
const handlers = createPluginApprovalHandlers(manager);
|
||||
const record = manager.create({ title: "T", description: "D" }, 60_000, "abcdef-1234");
|
||||
void manager.register(record, 60_000);
|
||||
|
||||
const opts = createMockOptions("plugin.approval.resolve", {
|
||||
id: "abcdef",
|
||||
decision: "allow-always",
|
||||
});
|
||||
await handlers["plugin.approval.resolve"](opts);
|
||||
expect(opts.respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
code: "INVALID_REQUEST",
|
||||
message: expect.stringContaining("unknown or expired"),
|
||||
details: expect.objectContaining({ reason: "APPROVAL_NOT_FOUND" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not leak candidate ids when prefixes are ambiguous", async () => {
|
||||
const handlers = createPluginApprovalHandlers(manager);
|
||||
const recordA = manager.create({ title: "A", description: "D" }, 60_000, "plugin:abc-1111");
|
||||
const recordB = manager.create({ title: "B", description: "D" }, 60_000, "plugin:abc-2222");
|
||||
void manager.register(recordA, 60_000);
|
||||
void manager.register(recordB, 60_000);
|
||||
|
||||
const opts = createMockOptions("plugin.approval.resolve", {
|
||||
id: "plugin:abc",
|
||||
decision: "deny",
|
||||
});
|
||||
await handlers["plugin.approval.resolve"](opts);
|
||||
expect(opts.respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "unknown or expired approval id",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,258 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import type { ExecApprovalForwarder } from "../../infra/exec-approval-forwarder.js";
|
||||
import type { ExecApprovalDecision } from "../../infra/exec-approvals.js";
|
||||
import type { PluginApprovalRequestPayload } from "../../infra/plugin-approvals.js";
|
||||
import {
|
||||
DEFAULT_PLUGIN_APPROVAL_TIMEOUT_MS,
|
||||
MAX_PLUGIN_APPROVAL_TIMEOUT_MS,
|
||||
} from "../../infra/plugin-approvals.js";
|
||||
import type { ExecApprovalManager } from "../exec-approval-manager.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
validatePluginApprovalRequestParams,
|
||||
validatePluginApprovalResolveParams,
|
||||
} from "../protocol/index.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
const APPROVAL_NOT_FOUND_DETAILS = {
|
||||
reason: ErrorCodes.APPROVAL_NOT_FOUND,
|
||||
} as const;
|
||||
|
||||
export function createPluginApprovalHandlers(
|
||||
manager: ExecApprovalManager<PluginApprovalRequestPayload>,
|
||||
opts?: { forwarder?: ExecApprovalForwarder },
|
||||
): GatewayRequestHandlers {
|
||||
return {
|
||||
"plugin.approval.request": async ({ params, client, respond, context }) => {
|
||||
if (!validatePluginApprovalRequestParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid plugin.approval.request params: ${formatValidationErrors(
|
||||
validatePluginApprovalRequestParams.errors,
|
||||
)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const p = params as {
|
||||
pluginId?: string | null;
|
||||
title: string;
|
||||
description: string;
|
||||
severity?: string | null;
|
||||
toolName?: string | null;
|
||||
toolCallId?: string | null;
|
||||
agentId?: string | null;
|
||||
sessionKey?: string | null;
|
||||
turnSourceChannel?: string | null;
|
||||
turnSourceTo?: string | null;
|
||||
turnSourceAccountId?: string | null;
|
||||
turnSourceThreadId?: string | number | null;
|
||||
timeoutMs?: number;
|
||||
twoPhase?: boolean;
|
||||
};
|
||||
const twoPhase = p.twoPhase === true;
|
||||
const timeoutMs = Math.min(
|
||||
typeof p.timeoutMs === "number" ? p.timeoutMs : DEFAULT_PLUGIN_APPROVAL_TIMEOUT_MS,
|
||||
MAX_PLUGIN_APPROVAL_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
const normalizeTrimmedString = (value?: string | null): string | null =>
|
||||
value?.trim() || null;
|
||||
|
||||
const request: PluginApprovalRequestPayload = {
|
||||
pluginId: p.pluginId ?? null,
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
severity: (p.severity as PluginApprovalRequestPayload["severity"]) ?? null,
|
||||
toolName: p.toolName ?? null,
|
||||
toolCallId: p.toolCallId ?? null,
|
||||
agentId: p.agentId ?? null,
|
||||
sessionKey: p.sessionKey ?? null,
|
||||
turnSourceChannel: normalizeTrimmedString(p.turnSourceChannel),
|
||||
turnSourceTo: normalizeTrimmedString(p.turnSourceTo),
|
||||
turnSourceAccountId: normalizeTrimmedString(p.turnSourceAccountId),
|
||||
turnSourceThreadId: p.turnSourceThreadId ?? null,
|
||||
};
|
||||
|
||||
// Always server-generate the ID — never accept plugin-provided IDs.
|
||||
// Kind-prefix so /approve routing can distinguish plugin vs exec IDs deterministically.
|
||||
const record = manager.create(request, timeoutMs, `plugin:${randomUUID()}`);
|
||||
|
||||
let decisionPromise: Promise<ExecApprovalDecision | null>;
|
||||
try {
|
||||
decisionPromise = manager.register(record, timeoutMs);
|
||||
} catch (err) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `registration failed: ${String(err)}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
context.broadcast(
|
||||
"plugin.approval.requested",
|
||||
{
|
||||
id: record.id,
|
||||
request: record.request,
|
||||
createdAtMs: record.createdAtMs,
|
||||
expiresAtMs: record.expiresAtMs,
|
||||
},
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
|
||||
let forwarded = false;
|
||||
if (opts?.forwarder?.handlePluginApprovalRequested) {
|
||||
try {
|
||||
forwarded = await opts.forwarder.handlePluginApprovalRequested({
|
||||
id: record.id,
|
||||
request: record.request,
|
||||
createdAtMs: record.createdAtMs,
|
||||
expiresAtMs: record.expiresAtMs,
|
||||
});
|
||||
} catch (err) {
|
||||
context.logGateway?.error?.(`plugin approvals: forward request failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const hasApprovalClients = context.hasExecApprovalClients?.(client?.connId) ?? false;
|
||||
if (!hasApprovalClients && !forwarded) {
|
||||
manager.expire(record.id, "no-approval-route");
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
id: record.id,
|
||||
decision: null,
|
||||
createdAtMs: record.createdAtMs,
|
||||
expiresAtMs: record.expiresAtMs,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (twoPhase) {
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
status: "accepted",
|
||||
id: record.id,
|
||||
createdAtMs: record.createdAtMs,
|
||||
expiresAtMs: record.expiresAtMs,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
const decision = await decisionPromise;
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
id: record.id,
|
||||
decision,
|
||||
createdAtMs: record.createdAtMs,
|
||||
expiresAtMs: record.expiresAtMs,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
},
|
||||
|
||||
"plugin.approval.waitDecision": async ({ params, respond }) => {
|
||||
const p = params as { id?: string };
|
||||
const id = typeof p.id === "string" ? p.id.trim() : "";
|
||||
if (!id) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "id is required"));
|
||||
return;
|
||||
}
|
||||
const decisionPromise = manager.awaitDecision(id);
|
||||
if (!decisionPromise) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "approval expired or not found"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const snapshot = manager.getSnapshot(id);
|
||||
const decision = await decisionPromise;
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
id,
|
||||
decision,
|
||||
createdAtMs: snapshot?.createdAtMs,
|
||||
expiresAtMs: snapshot?.expiresAtMs,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
},
|
||||
|
||||
"plugin.approval.resolve": async ({ params, respond, client, context }) => {
|
||||
if (!validatePluginApprovalResolveParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid plugin.approval.resolve params: ${formatValidationErrors(
|
||||
validatePluginApprovalResolveParams.errors,
|
||||
)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const p = params as { id: string; decision: string };
|
||||
const decision = p.decision as ExecApprovalDecision;
|
||||
if (decision !== "allow-once" && decision !== "allow-always" && decision !== "deny") {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid decision"));
|
||||
return;
|
||||
}
|
||||
const approvalId = p.id.trim();
|
||||
const snapshot = manager.getSnapshot(approvalId);
|
||||
if (!snapshot || snapshot.resolvedAtMs !== undefined) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id", {
|
||||
details: APPROVAL_NOT_FOUND_DETAILS,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const resolvedBy = client?.connect?.client?.displayName ?? client?.connect?.client?.id;
|
||||
const ok = manager.resolve(approvalId, decision, resolvedBy ?? null);
|
||||
if (!ok) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id", {
|
||||
details: APPROVAL_NOT_FOUND_DETAILS,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
context.broadcast(
|
||||
"plugin.approval.resolved",
|
||||
{ id: approvalId, decision, resolvedBy, ts: Date.now(), request: snapshot?.request },
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
void opts?.forwarder
|
||||
?.handlePluginApprovalResolved?.({
|
||||
id: approvalId,
|
||||
decision,
|
||||
resolvedBy,
|
||||
ts: Date.now(),
|
||||
request: snapshot?.request,
|
||||
})
|
||||
.catch((err) => {
|
||||
context.logGateway?.error?.(`plugin approvals: forward resolve failed: ${String(err)}`);
|
||||
});
|
||||
respond(true, { ok: true }, undefined);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -868,7 +868,9 @@ describe("exec approval handlers", () => {
|
|||
false,
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "unknown or expired approval id",
|
||||
details: expect.objectContaining({ reason: "APPROVAL_NOT_FOUND" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { ModelCatalogEntry } from "../../agents/model-catalog.js";
|
|||
import type { createDefaultDeps } from "../../cli/deps.js";
|
||||
import type { HealthSummary } from "../../commands/health.js";
|
||||
import type { CronService } from "../../cron/service.js";
|
||||
import type { PluginApprovalRequestPayload } from "../../infra/plugin-approvals.js";
|
||||
import type { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import type { WizardSession } from "../../wizard/session.js";
|
||||
import type { ChatAbortControllerEntry } from "../chat-abort.js";
|
||||
|
|
@ -39,6 +40,7 @@ export type GatewayRequestContext = {
|
|||
cron: CronService;
|
||||
cronStorePath: string;
|
||||
execApprovalManager?: ExecApprovalManager;
|
||||
pluginApprovalManager?: ExecApprovalManager<PluginApprovalRequestPayload>;
|
||||
loadGatewayModelCatalog: () => Promise<ModelCatalogEntry[]>;
|
||||
getHealthCache: () => HealthSummary | null;
|
||||
refreshHealthSnapshot: (opts?: { probe?: boolean }) => Promise<HealthSummary>;
|
||||
|
|
@ -54,7 +56,7 @@ export type GatewayRequestContext = {
|
|||
nodeUnsubscribe: (nodeId: string, sessionKey: string) => void;
|
||||
nodeUnsubscribeAll: (nodeId: string) => void;
|
||||
hasConnectedMobileNode: () => boolean;
|
||||
hasExecApprovalClients?: () => boolean;
|
||||
hasExecApprovalClients?: (excludeConnId?: string) => boolean;
|
||||
nodeRegistry: NodeRegistry;
|
||||
agentRunSeq: Map<string, number>;
|
||||
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ import { GATEWAY_EVENTS, listGatewayMethods } from "./server-methods-list.js";
|
|||
import { coreGatewayHandlers } from "./server-methods.js";
|
||||
import { createExecApprovalHandlers } from "./server-methods/exec-approval.js";
|
||||
import { safeParseJson } from "./server-methods/nodes.helpers.js";
|
||||
import { createPluginApprovalHandlers } from "./server-methods/plugin-approval.js";
|
||||
import { createSecretsHandlers } from "./server-methods/secrets.js";
|
||||
import { hasConnectedMobileNode } from "./server-mobile-nodes.js";
|
||||
import { loadGatewayModelCatalog } from "./server-model-catalog.js";
|
||||
|
|
@ -1127,6 +1128,12 @@ export async function startGatewayServer(
|
|||
const execApprovalHandlers = createExecApprovalHandlers(execApprovalManager, {
|
||||
forwarder: execApprovalForwarder,
|
||||
});
|
||||
const pluginApprovalManager = new ExecApprovalManager<
|
||||
import("../infra/plugin-approvals.js").PluginApprovalRequestPayload
|
||||
>();
|
||||
const pluginApprovalHandlers = createPluginApprovalHandlers(pluginApprovalManager, {
|
||||
forwarder: execApprovalForwarder,
|
||||
});
|
||||
const secretsHandlers = createSecretsHandlers({
|
||||
reloadSecrets: async () => {
|
||||
const active = getActiveSecretsRuntimeSnapshot();
|
||||
|
|
@ -1159,6 +1166,7 @@ export async function startGatewayServer(
|
|||
cron,
|
||||
cronStorePath,
|
||||
execApprovalManager,
|
||||
pluginApprovalManager,
|
||||
loadGatewayModelCatalog,
|
||||
getHealthCache,
|
||||
refreshHealthSnapshot: refreshGatewayHealthSnapshot,
|
||||
|
|
@ -1174,8 +1182,11 @@ export async function startGatewayServer(
|
|||
nodeUnsubscribe,
|
||||
nodeUnsubscribeAll,
|
||||
hasConnectedMobileNode: hasMobileNodeConnected,
|
||||
hasExecApprovalClients: () => {
|
||||
hasExecApprovalClients: (excludeConnId?: string) => {
|
||||
for (const gatewayClient of clients) {
|
||||
if (excludeConnId && gatewayClient.connId === excludeConnId) {
|
||||
continue;
|
||||
}
|
||||
const scopes = Array.isArray(gatewayClient.connect.scopes)
|
||||
? gatewayClient.connect.scopes
|
||||
: [];
|
||||
|
|
@ -1240,6 +1251,7 @@ export async function startGatewayServer(
|
|||
extraHandlers: {
|
||||
...pluginRegistry.gatewayHandlers,
|
||||
...execApprovalHandlers,
|
||||
...pluginApprovalHandlers,
|
||||
...secretsHandlers,
|
||||
},
|
||||
broadcast,
|
||||
|
|
|
|||
|
|
@ -17,12 +17,16 @@ import {
|
|||
} from "../utils/message-channel.js";
|
||||
import { resolveExecApprovalCommandDisplay } from "./exec-approval-command-display.js";
|
||||
import { resolveExecApprovalSessionTarget } from "./exec-approval-session-target.js";
|
||||
import type {
|
||||
ExecApprovalDecision,
|
||||
ExecApprovalRequest,
|
||||
ExecApprovalResolved,
|
||||
} from "./exec-approvals.js";
|
||||
import type { ExecApprovalRequest, ExecApprovalResolved } from "./exec-approvals.js";
|
||||
import { deliverOutboundPayloads } from "./outbound/deliver.js";
|
||||
import {
|
||||
approvalDecisionLabel,
|
||||
buildPluginApprovalExpiredMessage,
|
||||
buildPluginApprovalRequestMessage,
|
||||
buildPluginApprovalResolvedMessage,
|
||||
type PluginApprovalRequest,
|
||||
type PluginApprovalResolved,
|
||||
} from "./plugin-approvals.js";
|
||||
|
||||
const log = createSubsystemLogger("gateway/exec-approvals");
|
||||
export type { ExecApprovalRequest, ExecApprovalResolved };
|
||||
|
|
@ -38,6 +42,8 @@ type PendingApproval = {
|
|||
export type ExecApprovalForwarder = {
|
||||
handleRequested: (request: ExecApprovalRequest) => Promise<boolean>;
|
||||
handleResolved: (resolved: ExecApprovalResolved) => Promise<void>;
|
||||
handlePluginApprovalRequested?: (request: PluginApprovalRequest) => Promise<boolean>;
|
||||
handlePluginApprovalResolved?: (resolved: PluginApprovalResolved) => Promise<void>;
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
|
|
@ -182,15 +188,7 @@ function buildRequestMessage(request: ExecApprovalRequest, nowMs: number) {
|
|||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function decisionLabel(decision: ExecApprovalDecision): string {
|
||||
if (decision === "allow-once") {
|
||||
return "allowed once";
|
||||
}
|
||||
if (decision === "allow-always") {
|
||||
return "allowed always";
|
||||
}
|
||||
return "denied";
|
||||
}
|
||||
const decisionLabel = approvalDecisionLabel;
|
||||
|
||||
function buildResolvedMessage(resolved: ExecApprovalResolved) {
|
||||
const base = `✅ Exec approval ${decisionLabel(resolved.decision)}.`;
|
||||
|
|
@ -475,7 +473,186 @@ export function createExecApprovalForwarder(
|
|||
pending.clear();
|
||||
};
|
||||
|
||||
return { handleRequested, handleResolved, stop };
|
||||
const toSyntheticExecRequestFromPlugin = (params: {
|
||||
id: string;
|
||||
request: PluginApprovalRequest["request"];
|
||||
createdAtMs: number;
|
||||
expiresAtMs: number;
|
||||
}): ExecApprovalRequest => ({
|
||||
id: params.id,
|
||||
request: {
|
||||
command: params.request.title,
|
||||
agentId: params.request.agentId ?? null,
|
||||
sessionKey: params.request.sessionKey ?? null,
|
||||
turnSourceChannel: params.request.turnSourceChannel ?? null,
|
||||
turnSourceTo: params.request.turnSourceTo ?? null,
|
||||
turnSourceAccountId: params.request.turnSourceAccountId ?? null,
|
||||
turnSourceThreadId: params.request.turnSourceThreadId ?? null,
|
||||
},
|
||||
createdAtMs: params.createdAtMs,
|
||||
expiresAtMs: params.expiresAtMs,
|
||||
});
|
||||
|
||||
const pluginPending = new Map<string, PendingApproval>();
|
||||
|
||||
const handlePluginApprovalRequested = async (
|
||||
request: PluginApprovalRequest,
|
||||
): Promise<boolean> => {
|
||||
const cfg = getConfig();
|
||||
const config = cfg.approvals?.plugin;
|
||||
const syntheticExecRequest = toSyntheticExecRequestFromPlugin({
|
||||
id: request.id,
|
||||
request: request.request,
|
||||
createdAtMs: request.createdAtMs,
|
||||
expiresAtMs: request.expiresAtMs,
|
||||
});
|
||||
|
||||
const filteredTargets = [
|
||||
...(shouldForward({ config, request: syntheticExecRequest })
|
||||
? resolveForwardTargets({
|
||||
cfg,
|
||||
config,
|
||||
request: syntheticExecRequest,
|
||||
resolveSessionTarget,
|
||||
})
|
||||
: []),
|
||||
].filter(
|
||||
(target) => !shouldSkipForwardingFallback({ target, cfg, request: syntheticExecRequest }),
|
||||
);
|
||||
|
||||
if (filteredTargets.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expiresInMs = Math.max(0, request.expiresAtMs - nowMs());
|
||||
const timeoutId = setTimeout(() => {
|
||||
void (async () => {
|
||||
const entry = pluginPending.get(request.id);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
pluginPending.delete(request.id);
|
||||
const expiredText = buildPluginApprovalExpiredMessage(request);
|
||||
await deliverToTargets({
|
||||
cfg,
|
||||
targets: entry.targets,
|
||||
buildPayload: () => ({ text: expiredText }),
|
||||
deliver,
|
||||
});
|
||||
})();
|
||||
}, expiresInMs);
|
||||
timeoutId.unref?.();
|
||||
|
||||
const pendingEntry: PendingApproval = {
|
||||
request: syntheticExecRequest,
|
||||
targets: filteredTargets,
|
||||
timeoutId,
|
||||
};
|
||||
pluginPending.set(request.id, pendingEntry);
|
||||
|
||||
void deliverToTargets({
|
||||
cfg,
|
||||
targets: filteredTargets,
|
||||
buildPayload: (target) => {
|
||||
const channel = normalizeMessageChannel(target.channel) ?? target.channel;
|
||||
const adapterPayload = channel
|
||||
? getChannelPlugin(channel)?.execApprovals?.buildPluginPendingPayload?.({
|
||||
cfg,
|
||||
request,
|
||||
target,
|
||||
nowMs: nowMs(),
|
||||
})
|
||||
: null;
|
||||
return adapterPayload ?? { text: buildPluginApprovalRequestMessage(request, nowMs()) };
|
||||
},
|
||||
beforeDeliver: async (target, payload) => {
|
||||
const channel = normalizeMessageChannel(target.channel) ?? target.channel;
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
await getChannelPlugin(channel)?.execApprovals?.beforeDeliverPending?.({
|
||||
cfg,
|
||||
target,
|
||||
payload,
|
||||
});
|
||||
},
|
||||
deliver,
|
||||
shouldSend: () => pluginPending.get(request.id) === pendingEntry,
|
||||
}).catch((err) => {
|
||||
log.error(`plugin approvals: failed to deliver request ${request.id}: ${String(err)}`);
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const handlePluginApprovalResolved = async (resolved: PluginApprovalResolved) => {
|
||||
const cfg = getConfig();
|
||||
const entry = pluginPending.get(resolved.id);
|
||||
if (entry) {
|
||||
if (entry.timeoutId) {
|
||||
clearTimeout(entry.timeoutId);
|
||||
}
|
||||
pluginPending.delete(resolved.id);
|
||||
}
|
||||
let targets = entry?.targets;
|
||||
if (!targets && resolved.request) {
|
||||
const syntheticExecRequest = toSyntheticExecRequestFromPlugin({
|
||||
id: resolved.id,
|
||||
request: resolved.request,
|
||||
createdAtMs: resolved.ts,
|
||||
expiresAtMs: resolved.ts,
|
||||
});
|
||||
const config = cfg.approvals?.plugin;
|
||||
targets = [
|
||||
...(shouldForward({ config, request: syntheticExecRequest })
|
||||
? resolveForwardTargets({
|
||||
cfg,
|
||||
config,
|
||||
request: syntheticExecRequest,
|
||||
resolveSessionTarget,
|
||||
})
|
||||
: []),
|
||||
].filter(
|
||||
(target) => !shouldSkipForwardingFallback({ target, cfg, request: syntheticExecRequest }),
|
||||
);
|
||||
}
|
||||
if (!targets || targets.length === 0) {
|
||||
return;
|
||||
}
|
||||
await deliverToTargets({
|
||||
cfg,
|
||||
targets,
|
||||
buildPayload: (target) => {
|
||||
const channel = normalizeMessageChannel(target.channel) ?? target.channel;
|
||||
const adapterPayload = channel
|
||||
? getChannelPlugin(channel)?.execApprovals?.buildPluginResolvedPayload?.({
|
||||
cfg,
|
||||
resolved,
|
||||
target,
|
||||
})
|
||||
: null;
|
||||
return adapterPayload ?? { text: buildPluginApprovalResolvedMessage(resolved) };
|
||||
},
|
||||
deliver,
|
||||
});
|
||||
};
|
||||
|
||||
const stopAll = () => {
|
||||
stop();
|
||||
for (const entry of pluginPending.values()) {
|
||||
if (entry.timeoutId) {
|
||||
clearTimeout(entry.timeoutId);
|
||||
}
|
||||
}
|
||||
pluginPending.clear();
|
||||
};
|
||||
|
||||
return {
|
||||
handleRequested,
|
||||
handleResolved,
|
||||
handlePluginApprovalRequested,
|
||||
handlePluginApprovalResolved,
|
||||
stop: stopAll,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldForwardExecApproval(params: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,334 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import { createExecApprovalForwarder } from "./exec-approval-forwarder.js";
|
||||
import type { PluginApprovalRequest, PluginApprovalResolved } from "./plugin-approvals.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const emptyRegistry = createTestRegistry([]);
|
||||
|
||||
const PLUGIN_TARGETS_CFG = {
|
||||
approvals: {
|
||||
plugin: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "slack", to: "U123" }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const PLUGIN_DISABLED_CFG = {
|
||||
approvals: {
|
||||
plugin: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
function createForwarder(params: { cfg: OpenClawConfig; deliver?: ReturnType<typeof vi.fn> }) {
|
||||
const deliver = params.deliver ?? vi.fn().mockResolvedValue([]);
|
||||
const forwarder = createExecApprovalForwarder({
|
||||
getConfig: () => params.cfg,
|
||||
deliver: deliver as unknown as NonNullable<
|
||||
NonNullable<Parameters<typeof createExecApprovalForwarder>[0]>["deliver"]
|
||||
>,
|
||||
nowMs: () => 1000,
|
||||
});
|
||||
return { deliver, forwarder };
|
||||
}
|
||||
|
||||
function makePluginRequest(overrides?: Partial<PluginApprovalRequest>): PluginApprovalRequest {
|
||||
return {
|
||||
id: "plugin-req-1",
|
||||
request: {
|
||||
pluginId: "sage",
|
||||
title: "Sensitive tool call",
|
||||
description: "The agent wants to call a sensitive tool",
|
||||
severity: "warning",
|
||||
toolName: "bash",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:main",
|
||||
},
|
||||
createdAtMs: 1000,
|
||||
expiresAtMs: 6000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("plugin approval forwarding", () => {
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(emptyRegistry);
|
||||
});
|
||||
|
||||
describe("handlePluginApprovalRequested", () => {
|
||||
it("returns false when forwarding is disabled", async () => {
|
||||
const { forwarder } = createForwarder({ cfg: PLUGIN_DISABLED_CFG });
|
||||
const result = await forwarder.handlePluginApprovalRequested!(makePluginRequest());
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("forwards to configured targets", async () => {
|
||||
const deliver = vi.fn().mockResolvedValue([]);
|
||||
const { forwarder } = createForwarder({ cfg: PLUGIN_TARGETS_CFG, deliver });
|
||||
const result = await forwarder.handlePluginApprovalRequested!(makePluginRequest());
|
||||
expect(result).toBe(true);
|
||||
// Allow delivery to be async
|
||||
await vi.waitFor(() => {
|
||||
expect(deliver).toHaveBeenCalled();
|
||||
});
|
||||
const deliveryArgs = deliver.mock.calls[0]?.[0] as
|
||||
| { payloads?: Array<{ text?: string }> }
|
||||
| undefined;
|
||||
const text = deliveryArgs?.payloads?.[0]?.text ?? "";
|
||||
expect(text).toContain("Plugin approval required");
|
||||
expect(text).toContain("Sensitive tool call");
|
||||
expect(text).toContain("plugin-req-1");
|
||||
expect(text).toContain("/approve");
|
||||
});
|
||||
|
||||
it("includes severity icon for critical", async () => {
|
||||
const deliver = vi.fn().mockResolvedValue([]);
|
||||
const { forwarder } = createForwarder({ cfg: PLUGIN_TARGETS_CFG, deliver });
|
||||
const request = makePluginRequest();
|
||||
request.request.severity = "critical";
|
||||
await forwarder.handlePluginApprovalRequested!(request);
|
||||
await vi.waitFor(() => {
|
||||
expect(deliver).toHaveBeenCalled();
|
||||
});
|
||||
const text =
|
||||
(deliver.mock.calls[0]?.[0] as { payloads?: Array<{ text?: string }> })?.payloads?.[0]
|
||||
?.text ?? "";
|
||||
expect(text).toMatch(/🚨/);
|
||||
});
|
||||
|
||||
it("returns false when exec enabled but plugin disabled", async () => {
|
||||
const cfg = {
|
||||
approvals: {
|
||||
exec: { enabled: true, mode: "targets", targets: [{ channel: "slack", to: "U123" }] },
|
||||
plugin: { enabled: false },
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const { forwarder } = createForwarder({ cfg });
|
||||
const result = await forwarder.handlePluginApprovalRequested!(makePluginRequest());
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("forwards when plugin enabled but exec disabled", async () => {
|
||||
const cfg = {
|
||||
approvals: {
|
||||
exec: { enabled: false },
|
||||
plugin: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "slack", to: "U123" }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const deliver = vi.fn().mockResolvedValue([]);
|
||||
const { forwarder } = createForwarder({ cfg, deliver });
|
||||
const result = await forwarder.handlePluginApprovalRequested!(makePluginRequest());
|
||||
expect(result).toBe(true);
|
||||
await vi.waitFor(() => {
|
||||
expect(deliver).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("returns false when no approvals config at all", async () => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const { forwarder } = createForwarder({ cfg });
|
||||
const result = await forwarder.handlePluginApprovalRequested!(makePluginRequest());
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("channel adapter hooks", () => {
|
||||
it("uses buildPluginPendingPayload from channel adapter when available", async () => {
|
||||
const mockPayload = { text: "custom adapter payload" };
|
||||
const adapterPlugin: Pick<
|
||||
ChannelPlugin,
|
||||
"id" | "meta" | "capabilities" | "config" | "execApprovals"
|
||||
> = {
|
||||
...createChannelTestPluginBase({ id: "slack" as ChannelPlugin["id"] }),
|
||||
execApprovals: {
|
||||
buildPluginPendingPayload: vi.fn().mockReturnValue(mockPayload),
|
||||
},
|
||||
};
|
||||
const registry = createTestRegistry([
|
||||
{ pluginId: "slack", plugin: adapterPlugin, source: "test" },
|
||||
]);
|
||||
setActivePluginRegistry(registry);
|
||||
|
||||
const deliver = vi.fn().mockResolvedValue([]);
|
||||
const { forwarder } = createForwarder({ cfg: PLUGIN_TARGETS_CFG, deliver });
|
||||
await forwarder.handlePluginApprovalRequested!(makePluginRequest());
|
||||
await vi.waitFor(() => {
|
||||
expect(deliver).toHaveBeenCalled();
|
||||
});
|
||||
const deliveryArgs = deliver.mock.calls[0]?.[0] as
|
||||
| { payloads?: Array<{ text?: string }> }
|
||||
| undefined;
|
||||
expect(deliveryArgs?.payloads?.[0]?.text).toBe("custom adapter payload");
|
||||
});
|
||||
|
||||
it("falls back to plugin text when no adapter exists", async () => {
|
||||
const deliver = vi.fn().mockResolvedValue([]);
|
||||
const { forwarder } = createForwarder({ cfg: PLUGIN_TARGETS_CFG, deliver });
|
||||
await forwarder.handlePluginApprovalRequested!(makePluginRequest());
|
||||
await vi.waitFor(() => {
|
||||
expect(deliver).toHaveBeenCalled();
|
||||
});
|
||||
const text =
|
||||
(deliver.mock.calls[0]?.[0] as { payloads?: Array<{ text?: string }> })?.payloads?.[0]
|
||||
?.text ?? "";
|
||||
expect(text).toContain("Plugin approval required");
|
||||
});
|
||||
|
||||
it("calls beforeDeliverPending before plugin approval delivery", async () => {
|
||||
const beforeDeliverPending = vi.fn();
|
||||
const adapterPlugin: Pick<
|
||||
ChannelPlugin,
|
||||
"id" | "meta" | "capabilities" | "config" | "execApprovals"
|
||||
> = {
|
||||
...createChannelTestPluginBase({ id: "slack" as ChannelPlugin["id"] }),
|
||||
execApprovals: {
|
||||
beforeDeliverPending,
|
||||
},
|
||||
};
|
||||
const registry = createTestRegistry([
|
||||
{ pluginId: "slack", plugin: adapterPlugin, source: "test" },
|
||||
]);
|
||||
setActivePluginRegistry(registry);
|
||||
|
||||
const deliver = vi.fn().mockResolvedValue([]);
|
||||
const { forwarder } = createForwarder({ cfg: PLUGIN_TARGETS_CFG, deliver });
|
||||
await forwarder.handlePluginApprovalRequested!(makePluginRequest());
|
||||
await vi.waitFor(() => {
|
||||
expect(deliver).toHaveBeenCalled();
|
||||
});
|
||||
expect(beforeDeliverPending).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses buildPluginResolvedPayload from channel adapter for resolved messages", async () => {
|
||||
const mockPayload = { text: "custom resolved payload" };
|
||||
const adapterPlugin: Pick<
|
||||
ChannelPlugin,
|
||||
"id" | "meta" | "capabilities" | "config" | "execApprovals"
|
||||
> = {
|
||||
...createChannelTestPluginBase({ id: "slack" as ChannelPlugin["id"] }),
|
||||
execApprovals: {
|
||||
buildPluginResolvedPayload: vi.fn().mockReturnValue(mockPayload),
|
||||
},
|
||||
};
|
||||
const registry = createTestRegistry([
|
||||
{ pluginId: "slack", plugin: adapterPlugin, source: "test" },
|
||||
]);
|
||||
setActivePluginRegistry(registry);
|
||||
|
||||
const deliver = vi.fn().mockResolvedValue([]);
|
||||
const { forwarder } = createForwarder({ cfg: PLUGIN_TARGETS_CFG, deliver });
|
||||
|
||||
// First register request so targets are tracked
|
||||
await forwarder.handlePluginApprovalRequested!(makePluginRequest());
|
||||
await vi.waitFor(() => {
|
||||
expect(deliver).toHaveBeenCalled();
|
||||
});
|
||||
deliver.mockClear();
|
||||
|
||||
const resolved: PluginApprovalResolved = {
|
||||
id: "plugin-req-1",
|
||||
decision: "allow-once",
|
||||
resolvedBy: "telegram:user123",
|
||||
ts: 2000,
|
||||
};
|
||||
await forwarder.handlePluginApprovalResolved!(resolved);
|
||||
expect(deliver).toHaveBeenCalled();
|
||||
const deliveryArgs = deliver.mock.calls[0]?.[0] as
|
||||
| { payloads?: Array<{ text?: string }> }
|
||||
| undefined;
|
||||
expect(deliveryArgs?.payloads?.[0]?.text).toBe("custom resolved payload");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handlePluginApprovalResolved", () => {
|
||||
it("delivers resolved message to targets", async () => {
|
||||
const deliver = vi.fn().mockResolvedValue([]);
|
||||
const { forwarder } = createForwarder({ cfg: PLUGIN_TARGETS_CFG, deliver });
|
||||
|
||||
// First register request so targets are tracked
|
||||
await forwarder.handlePluginApprovalRequested!(makePluginRequest());
|
||||
await vi.waitFor(() => {
|
||||
expect(deliver).toHaveBeenCalled();
|
||||
});
|
||||
deliver.mockClear();
|
||||
|
||||
const resolved: PluginApprovalResolved = {
|
||||
id: "plugin-req-1",
|
||||
decision: "allow-once",
|
||||
resolvedBy: "telegram:user123",
|
||||
ts: 2000,
|
||||
};
|
||||
await forwarder.handlePluginApprovalResolved!(resolved);
|
||||
expect(deliver).toHaveBeenCalled();
|
||||
const text =
|
||||
(deliver.mock.calls[0]?.[0] as { payloads?: Array<{ text?: string }> })?.payloads?.[0]
|
||||
?.text ?? "";
|
||||
expect(text).toContain("Plugin approval");
|
||||
expect(text).toContain("allowed once");
|
||||
});
|
||||
|
||||
it("reconstructs targets from resolved request snapshot when pending cache is missing", async () => {
|
||||
const deliver = vi.fn().mockResolvedValue([]);
|
||||
const { forwarder } = createForwarder({ cfg: PLUGIN_TARGETS_CFG, deliver });
|
||||
|
||||
await forwarder.handlePluginApprovalResolved!({
|
||||
id: "plugin-req-late",
|
||||
decision: "deny",
|
||||
resolvedBy: "telegram:user123",
|
||||
ts: 2_000,
|
||||
request: {
|
||||
pluginId: "sage",
|
||||
title: "Sensitive tool call",
|
||||
description: "The agent wants to call a sensitive tool",
|
||||
severity: "warning",
|
||||
toolName: "bash",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:main",
|
||||
},
|
||||
});
|
||||
|
||||
expect(deliver).toHaveBeenCalled();
|
||||
const text =
|
||||
(deliver.mock.calls[0]?.[0] as { payloads?: Array<{ text?: string }> })?.payloads?.[0]
|
||||
?.text ?? "";
|
||||
expect(text).toContain("Plugin approval");
|
||||
expect(text).toContain("denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stop", () => {
|
||||
it("clears pending plugin approvals", async () => {
|
||||
const deliver = vi.fn().mockResolvedValue([]);
|
||||
const { forwarder } = createForwarder({ cfg: PLUGIN_TARGETS_CFG, deliver });
|
||||
await forwarder.handlePluginApprovalRequested!(makePluginRequest());
|
||||
// Wait for the async delivery to flush before stopping
|
||||
await vi.waitFor(() => {
|
||||
expect(deliver).toHaveBeenCalled();
|
||||
});
|
||||
forwarder.stop();
|
||||
deliver.mockClear();
|
||||
// After stop, resolved should not deliver
|
||||
await forwarder.handlePluginApprovalResolved!({
|
||||
id: "plugin-req-1",
|
||||
decision: "deny",
|
||||
ts: 2000,
|
||||
});
|
||||
expect(deliver).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import type { ExecApprovalDecision } from "./exec-approvals.js";
|
||||
|
||||
export type PluginApprovalRequestPayload = {
|
||||
pluginId?: string | null;
|
||||
title: string;
|
||||
description: string;
|
||||
severity?: "info" | "warning" | "critical" | null;
|
||||
toolName?: string | null;
|
||||
toolCallId?: string | null;
|
||||
agentId?: string | null;
|
||||
sessionKey?: string | null;
|
||||
turnSourceChannel?: string | null;
|
||||
turnSourceTo?: string | null;
|
||||
turnSourceAccountId?: string | null;
|
||||
turnSourceThreadId?: string | number | null;
|
||||
};
|
||||
|
||||
export type PluginApprovalRequest = {
|
||||
id: string;
|
||||
request: PluginApprovalRequestPayload;
|
||||
createdAtMs: number;
|
||||
expiresAtMs: number;
|
||||
};
|
||||
|
||||
export type PluginApprovalResolved = {
|
||||
id: string;
|
||||
decision: ExecApprovalDecision;
|
||||
resolvedBy?: string | null;
|
||||
ts: number;
|
||||
request?: PluginApprovalRequestPayload;
|
||||
};
|
||||
|
||||
export const DEFAULT_PLUGIN_APPROVAL_TIMEOUT_MS = 120_000;
|
||||
export const MAX_PLUGIN_APPROVAL_TIMEOUT_MS = 600_000;
|
||||
export const PLUGIN_APPROVAL_TITLE_MAX_LENGTH = 80;
|
||||
export const PLUGIN_APPROVAL_DESCRIPTION_MAX_LENGTH = 256;
|
||||
|
||||
export function approvalDecisionLabel(decision: ExecApprovalDecision): string {
|
||||
if (decision === "allow-once") {
|
||||
return "allowed once";
|
||||
}
|
||||
if (decision === "allow-always") {
|
||||
return "allowed always";
|
||||
}
|
||||
return "denied";
|
||||
}
|
||||
|
||||
export function buildPluginApprovalRequestMessage(
|
||||
request: PluginApprovalRequest,
|
||||
nowMsValue: number,
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
const severity = request.request.severity ?? "warning";
|
||||
const icon = severity === "critical" ? "🚨" : severity === "info" ? "ℹ️" : "🛡️";
|
||||
lines.push(`${icon} Plugin approval required`);
|
||||
lines.push(`Title: ${request.request.title}`);
|
||||
lines.push(`Description: ${request.request.description}`);
|
||||
if (request.request.toolName) {
|
||||
lines.push(`Tool: ${request.request.toolName}`);
|
||||
}
|
||||
if (request.request.pluginId) {
|
||||
lines.push(`Plugin: ${request.request.pluginId}`);
|
||||
}
|
||||
if (request.request.agentId) {
|
||||
lines.push(`Agent: ${request.request.agentId}`);
|
||||
}
|
||||
lines.push(`ID: ${request.id}`);
|
||||
const expiresIn = Math.max(0, Math.round((request.expiresAtMs - nowMsValue) / 1000));
|
||||
lines.push(`Expires in: ${expiresIn}s`);
|
||||
lines.push("Reply with: /approve <id> allow-once|allow-always|deny");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function buildPluginApprovalResolvedMessage(resolved: PluginApprovalResolved): string {
|
||||
const base = `✅ Plugin approval ${approvalDecisionLabel(resolved.decision)}.`;
|
||||
const by = resolved.resolvedBy ? ` Resolved by ${resolved.resolvedBy}.` : "";
|
||||
return `${base}${by} ID: ${resolved.id}`;
|
||||
}
|
||||
|
||||
export function buildPluginApprovalExpiredMessage(request: PluginApprovalRequest): string {
|
||||
return `⏱️ Plugin approval expired. ID: ${request.id}`;
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ export * from "../infra/exec-approval-command-display.ts";
|
|||
export * from "../infra/exec-approval-reply.ts";
|
||||
export * from "../infra/exec-approval-session-target.ts";
|
||||
export * from "../infra/exec-approvals.ts";
|
||||
export * from "../infra/plugin-approvals.ts";
|
||||
export * from "../infra/fetch.js";
|
||||
export * from "../infra/file-lock.js";
|
||||
export * from "../infra/format-time/format-duration.ts";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,209 @@
|
|||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { createHookRunner } from "./hooks.js";
|
||||
import { addTestHook } from "./hooks.test-helpers.js";
|
||||
import { createEmptyPluginRegistry, type PluginRegistry } from "./registry.js";
|
||||
import type { PluginHookToolContext } from "./types.js";
|
||||
import type { PluginHookBeforeToolCallResult, PluginHookRegistration } from "./types.js";
|
||||
|
||||
function addBeforeToolCallHook(
|
||||
registry: PluginRegistry,
|
||||
pluginId: string,
|
||||
handler: () => PluginHookBeforeToolCallResult | Promise<PluginHookBeforeToolCallResult>,
|
||||
priority?: number,
|
||||
) {
|
||||
addTestHook({
|
||||
registry,
|
||||
pluginId,
|
||||
hookName: "before_tool_call",
|
||||
handler: handler as PluginHookRegistration["handler"],
|
||||
priority,
|
||||
});
|
||||
}
|
||||
|
||||
const stubCtx: PluginHookToolContext = {
|
||||
toolName: "bash",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:main",
|
||||
};
|
||||
|
||||
describe("before_tool_call hook merger — requireApproval", () => {
|
||||
let registry: PluginRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = createEmptyPluginRegistry();
|
||||
});
|
||||
|
||||
it("propagates requireApproval from a single plugin", async () => {
|
||||
addBeforeToolCallHook(registry, "sage", () => ({
|
||||
requireApproval: {
|
||||
id: "approval-1",
|
||||
title: "Sensitive tool",
|
||||
description: "This tool does something sensitive",
|
||||
severity: "warning",
|
||||
},
|
||||
}));
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeToolCall({ toolName: "bash", params: {} }, stubCtx);
|
||||
expect(result?.requireApproval).toEqual({
|
||||
id: "approval-1",
|
||||
title: "Sensitive tool",
|
||||
description: "This tool does something sensitive",
|
||||
severity: "warning",
|
||||
pluginId: "sage",
|
||||
});
|
||||
});
|
||||
|
||||
it("stamps pluginId from the registration", async () => {
|
||||
addBeforeToolCallHook(registry, "my-plugin", () => ({
|
||||
requireApproval: {
|
||||
id: "a1",
|
||||
title: "T",
|
||||
description: "D",
|
||||
},
|
||||
}));
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeToolCall({ toolName: "bash", params: {} }, stubCtx);
|
||||
expect(result?.requireApproval?.pluginId).toBe("my-plugin");
|
||||
});
|
||||
|
||||
it("first hook with requireApproval wins when multiple plugins set it", async () => {
|
||||
addBeforeToolCallHook(
|
||||
registry,
|
||||
"plugin-a",
|
||||
() => ({
|
||||
requireApproval: {
|
||||
title: "First",
|
||||
description: "First plugin",
|
||||
},
|
||||
}),
|
||||
100,
|
||||
);
|
||||
addBeforeToolCallHook(
|
||||
registry,
|
||||
"plugin-b",
|
||||
() => ({
|
||||
requireApproval: {
|
||||
title: "Second",
|
||||
description: "Second plugin",
|
||||
},
|
||||
}),
|
||||
50,
|
||||
);
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeToolCall({ toolName: "bash", params: {} }, stubCtx);
|
||||
expect(result?.requireApproval?.title).toBe("First");
|
||||
expect(result?.requireApproval?.pluginId).toBe("plugin-a");
|
||||
});
|
||||
|
||||
it("does not overwrite pluginId if plugin sets it (stamped by merger)", async () => {
|
||||
addBeforeToolCallHook(registry, "actual-plugin", () => ({
|
||||
requireApproval: {
|
||||
title: "T",
|
||||
description: "D",
|
||||
pluginId: "should-be-overwritten",
|
||||
},
|
||||
}));
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeToolCall({ toolName: "bash", params: {} }, stubCtx);
|
||||
// The merger spreads the requireApproval then overwrites pluginId from registration
|
||||
expect(result?.requireApproval?.pluginId).toBe("actual-plugin");
|
||||
});
|
||||
|
||||
it("merges block and requireApproval from different plugins", async () => {
|
||||
addBeforeToolCallHook(
|
||||
registry,
|
||||
"approver",
|
||||
() => ({
|
||||
requireApproval: {
|
||||
title: "Needs approval",
|
||||
description: "Approval needed",
|
||||
},
|
||||
}),
|
||||
100,
|
||||
);
|
||||
addBeforeToolCallHook(
|
||||
registry,
|
||||
"blocker",
|
||||
() => ({
|
||||
block: true,
|
||||
blockReason: "blocked",
|
||||
}),
|
||||
50,
|
||||
);
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeToolCall({ toolName: "bash", params: {} }, stubCtx);
|
||||
expect(result?.block).toBe(true);
|
||||
expect(result?.requireApproval?.title).toBe("Needs approval");
|
||||
});
|
||||
|
||||
it("returns undefined requireApproval when no plugin sets it", async () => {
|
||||
addBeforeToolCallHook(registry, "plain", () => ({
|
||||
params: { extra: true },
|
||||
}));
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeToolCall({ toolName: "bash", params: {} }, stubCtx);
|
||||
expect(result?.requireApproval).toBeUndefined();
|
||||
});
|
||||
|
||||
it("freezes params after requireApproval when a lower-priority plugin tries to override them", async () => {
|
||||
addBeforeToolCallHook(
|
||||
registry,
|
||||
"approver",
|
||||
() => ({
|
||||
params: { source: "approver", safe: true },
|
||||
requireApproval: {
|
||||
title: "Needs approval",
|
||||
description: "Approval needed",
|
||||
},
|
||||
}),
|
||||
100,
|
||||
);
|
||||
addBeforeToolCallHook(
|
||||
registry,
|
||||
"mutator",
|
||||
() => ({
|
||||
params: { source: "mutator", safe: false },
|
||||
}),
|
||||
50,
|
||||
);
|
||||
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeToolCall({ toolName: "bash", params: {} }, stubCtx);
|
||||
|
||||
expect(result?.requireApproval?.pluginId).toBe("approver");
|
||||
expect(result?.params).toEqual({ source: "approver", safe: true });
|
||||
});
|
||||
|
||||
it("still allows block=true from a lower-priority plugin after requireApproval", async () => {
|
||||
addBeforeToolCallHook(
|
||||
registry,
|
||||
"approver",
|
||||
() => ({
|
||||
params: { source: "approver", safe: true },
|
||||
requireApproval: {
|
||||
title: "Needs approval",
|
||||
description: "Approval needed",
|
||||
},
|
||||
}),
|
||||
100,
|
||||
);
|
||||
addBeforeToolCallHook(
|
||||
registry,
|
||||
"blocker",
|
||||
() => ({
|
||||
block: true,
|
||||
blockReason: "blocked",
|
||||
params: { source: "blocker", safe: false },
|
||||
}),
|
||||
50,
|
||||
);
|
||||
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeToolCall({ toolName: "bash", params: {} }, stubCtx);
|
||||
|
||||
expect(result?.block).toBe(true);
|
||||
expect(result?.blockReason).toBe("blocked");
|
||||
expect(result?.requireApproval?.pluginId).toBe("approver");
|
||||
expect(result?.params).toEqual({ source: "approver", safe: true });
|
||||
});
|
||||
});
|
||||
|
|
@ -121,7 +121,11 @@ export type HookRunnerOptions = {
|
|||
};
|
||||
|
||||
type ModifyingHookPolicy<K extends PluginHookName, TResult> = {
|
||||
mergeResults?: (accumulated: TResult | undefined, next: TResult) => TResult;
|
||||
mergeResults?: (
|
||||
accumulated: TResult | undefined,
|
||||
next: TResult,
|
||||
registration: PluginHookRegistration<K>,
|
||||
) => TResult;
|
||||
shouldStop?: (result: TResult) => boolean;
|
||||
terminalLabel?: string;
|
||||
onTerminal?: (params: { hookName: K; pluginId: string; result: TResult }) => void;
|
||||
|
|
@ -307,7 +311,7 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
|
|||
|
||||
if (handlerResult !== undefined && handlerResult !== null) {
|
||||
if (policy.mergeResults) {
|
||||
result = policy.mergeResults(result, handlerResult);
|
||||
result = policy.mergeResults(result, handlerResult, hook);
|
||||
} else {
|
||||
result = handlerResult;
|
||||
}
|
||||
|
|
@ -694,14 +698,24 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
|
|||
event,
|
||||
ctx,
|
||||
{
|
||||
mergeResults: (acc, next) => {
|
||||
mergeResults: (acc, next, reg) => {
|
||||
if (acc?.block === true) {
|
||||
return acc;
|
||||
}
|
||||
const approvalPluginId = acc?.requireApproval?.pluginId;
|
||||
const freezeParamsForDifferentPlugin =
|
||||
Boolean(approvalPluginId) && approvalPluginId !== reg.pluginId;
|
||||
return {
|
||||
params: lastDefined(acc?.params, next.params),
|
||||
params: freezeParamsForDifferentPlugin
|
||||
? acc?.params
|
||||
: lastDefined(acc?.params, next.params),
|
||||
block: stickyTrue(acc?.block, next.block),
|
||||
blockReason: lastDefined(acc?.blockReason, next.blockReason),
|
||||
requireApproval:
|
||||
acc?.requireApproval ??
|
||||
(next.requireApproval
|
||||
? { ...next.requireApproval, pluginId: reg.pluginId }
|
||||
: undefined),
|
||||
};
|
||||
},
|
||||
shouldStop: (result) => result.block === true,
|
||||
|
|
|
|||
|
|
@ -1966,10 +1966,35 @@ export type PluginHookBeforeToolCallEvent = {
|
|||
toolCallId?: string;
|
||||
};
|
||||
|
||||
export const PluginApprovalResolutions = {
|
||||
ALLOW_ONCE: "allow-once",
|
||||
ALLOW_ALWAYS: "allow-always",
|
||||
DENY: "deny",
|
||||
TIMEOUT: "timeout",
|
||||
CANCELLED: "cancelled",
|
||||
} as const;
|
||||
|
||||
export type PluginApprovalResolution =
|
||||
(typeof PluginApprovalResolutions)[keyof typeof PluginApprovalResolutions];
|
||||
|
||||
export type PluginHookBeforeToolCallResult = {
|
||||
params?: Record<string, unknown>;
|
||||
block?: boolean;
|
||||
blockReason?: string;
|
||||
requireApproval?: {
|
||||
title: string;
|
||||
description: string;
|
||||
severity?: "info" | "warning" | "critical";
|
||||
timeoutMs?: number;
|
||||
timeoutBehavior?: "allow" | "deny";
|
||||
/** Set automatically by the hook runner — plugins should not set this. */
|
||||
pluginId?: string;
|
||||
/**
|
||||
* Best-effort callback invoked with the final outcome after approval resolves, times out, or is cancelled.
|
||||
* OpenClaw does not await this callback before allowing or denying the tool call.
|
||||
*/
|
||||
onResolution?: (decision: PluginApprovalResolution) => Promise<void> | void;
|
||||
};
|
||||
};
|
||||
|
||||
// after_tool_call hook
|
||||
|
|
|
|||
|
|
@ -492,6 +492,62 @@ describe("connectGateway", () => {
|
|||
expect(loadChatHistoryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes plugin.approval.requested into execApprovalQueue with kind plugin", () => {
|
||||
const host = createHost();
|
||||
|
||||
connectGateway(host);
|
||||
const client = gatewayClientInstances[0];
|
||||
expect(client).toBeDefined();
|
||||
|
||||
client.emitEvent({
|
||||
event: "plugin.approval.requested",
|
||||
payload: {
|
||||
id: "plugin-approval-1",
|
||||
createdAtMs: Date.now(),
|
||||
expiresAtMs: Date.now() + 120_000,
|
||||
request: {
|
||||
title: "Dangerous command detected",
|
||||
description: "chmod 777 script.sh",
|
||||
severity: "high",
|
||||
pluginId: "sage",
|
||||
agentId: "agent-1",
|
||||
sessionKey: "main",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(host.execApprovalQueue).toHaveLength(1);
|
||||
expect(host.execApprovalQueue[0]?.id).toBe("plugin-approval-1");
|
||||
expect((host.execApprovalQueue[0] as { kind: string }).kind).toBe("plugin");
|
||||
});
|
||||
|
||||
it("routes plugin.approval.resolved to remove from execApprovalQueue", () => {
|
||||
const host = createHost();
|
||||
|
||||
connectGateway(host);
|
||||
const client = gatewayClientInstances[0];
|
||||
expect(client).toBeDefined();
|
||||
|
||||
// Add a plugin approval first
|
||||
client.emitEvent({
|
||||
event: "plugin.approval.requested",
|
||||
payload: {
|
||||
id: "plugin-approval-2",
|
||||
createdAtMs: Date.now(),
|
||||
expiresAtMs: Date.now() + 120_000,
|
||||
request: { title: "Alert" },
|
||||
},
|
||||
});
|
||||
expect(host.execApprovalQueue).toHaveLength(1);
|
||||
|
||||
// Resolve it
|
||||
client.emitEvent({
|
||||
event: "plugin.approval.resolved",
|
||||
payload: { id: "plugin-approval-2", decision: "allow-once" },
|
||||
});
|
||||
expect(host.execApprovalQueue).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reloads chat history once after the final chat event when tool output was used", () => {
|
||||
const { client } = connectHostGateway();
|
||||
emitToolResultEvent(client);
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
addExecApproval,
|
||||
parseExecApprovalRequested,
|
||||
parseExecApprovalResolved,
|
||||
parsePluginApprovalRequested,
|
||||
removeExecApproval,
|
||||
} from "./controllers/exec-approval.ts";
|
||||
import { loadHealthState } from "./controllers/health.ts";
|
||||
|
|
@ -406,6 +407,27 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (evt.event === "plugin.approval.requested") {
|
||||
const entry = parsePluginApprovalRequested(evt.payload);
|
||||
if (entry) {
|
||||
host.execApprovalQueue = addExecApproval(host.execApprovalQueue, entry);
|
||||
host.execApprovalError = null;
|
||||
const delay = Math.max(0, entry.expiresAtMs - Date.now() + 500);
|
||||
window.setTimeout(() => {
|
||||
host.execApprovalQueue = removeExecApproval(host.execApprovalQueue, entry.id);
|
||||
}, delay);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.event === "plugin.approval.resolved") {
|
||||
const resolved = parseExecApprovalResolved(evt.payload);
|
||||
if (resolved) {
|
||||
host.execApprovalQueue = removeExecApproval(host.execApprovalQueue, resolved.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.event === GATEWAY_EVENT_UPDATE_AVAILABLE) {
|
||||
const payload = evt.payload as GatewayUpdateAvailableEventPayload | undefined;
|
||||
host.updateAvailable = payload?.updateAvailable ?? null;
|
||||
|
|
|
|||
|
|
@ -690,13 +690,14 @@ export class OpenClawApp extends LitElement {
|
|||
this.execApprovalBusy = true;
|
||||
this.execApprovalError = null;
|
||||
try {
|
||||
await this.client.request("exec.approval.resolve", {
|
||||
const method = active.kind === "plugin" ? "plugin.approval.resolve" : "exec.approval.resolve";
|
||||
await this.client.request(method, {
|
||||
id: active.id,
|
||||
decision,
|
||||
});
|
||||
this.execApprovalQueue = this.execApprovalQueue.filter((entry) => entry.id !== active.id);
|
||||
} catch (err) {
|
||||
this.execApprovalError = `Exec approval failed: ${String(err)}`;
|
||||
this.execApprovalError = `Approval failed: ${String(err)}`;
|
||||
} finally {
|
||||
this.execApprovalBusy = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { parseExecApprovalRequested, parsePluginApprovalRequested } from "./exec-approval.ts";
|
||||
|
||||
describe("parseExecApprovalRequested", () => {
|
||||
it("returns entries with kind 'exec'", () => {
|
||||
const result = parseExecApprovalRequested({
|
||||
id: "exec-1",
|
||||
request: { command: "rm -rf /" },
|
||||
createdAtMs: 1000,
|
||||
expiresAtMs: 2000,
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.kind).toBe("exec");
|
||||
expect(result!.request.command).toBe("rm -rf /");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePluginApprovalRequested", () => {
|
||||
// Matches the actual gateway broadcast shape: title/description/severity/pluginId
|
||||
// are nested inside payload.request (PluginApprovalRequestPayload)
|
||||
const validPayload = {
|
||||
id: "plugin-1",
|
||||
createdAtMs: 1000,
|
||||
expiresAtMs: 120_000,
|
||||
request: {
|
||||
title: "Dangerous command detected",
|
||||
description: "chmod 777 script.sh modifies file permissions",
|
||||
severity: "high",
|
||||
pluginId: "sage",
|
||||
agentId: "agent-1",
|
||||
sessionKey: "sess-1",
|
||||
},
|
||||
};
|
||||
|
||||
it("parses a valid payload", () => {
|
||||
const result = parsePluginApprovalRequested(validPayload);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.kind).toBe("plugin");
|
||||
expect(result!.pluginTitle).toBe("Dangerous command detected");
|
||||
expect(result!.pluginDescription).toBe("chmod 777 script.sh modifies file permissions");
|
||||
expect(result!.pluginSeverity).toBe("high");
|
||||
expect(result!.pluginId).toBe("sage");
|
||||
expect(result!.request.command).toBe("Dangerous command detected");
|
||||
expect(result!.request.agentId).toBe("agent-1");
|
||||
expect(result!.request.sessionKey).toBe("sess-1");
|
||||
expect(result!.createdAtMs).toBe(1000);
|
||||
expect(result!.expiresAtMs).toBe(120_000);
|
||||
});
|
||||
|
||||
it("returns null when title is missing from request", () => {
|
||||
const {
|
||||
request: { title: _, ...restRequest },
|
||||
...rest
|
||||
} = validPayload;
|
||||
expect(parsePluginApprovalRequested({ ...rest, request: restRequest })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when request is missing entirely", () => {
|
||||
const { request: _, ...noRequest } = validPayload;
|
||||
expect(parsePluginApprovalRequested(noRequest)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when id is missing", () => {
|
||||
const { id: _, ...noId } = validPayload;
|
||||
expect(parsePluginApprovalRequested(noId)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when timestamps are missing", () => {
|
||||
const { createdAtMs: _, expiresAtMs: __, ...noTimestamps } = validPayload;
|
||||
expect(parsePluginApprovalRequested(noTimestamps)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for null payload", () => {
|
||||
expect(parsePluginApprovalRequested(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-object payload", () => {
|
||||
expect(parsePluginApprovalRequested("not an object")).toBeNull();
|
||||
});
|
||||
|
||||
it("handles missing optional fields gracefully", () => {
|
||||
const minimal = {
|
||||
id: "plugin-2",
|
||||
createdAtMs: 500,
|
||||
expiresAtMs: 60_000,
|
||||
request: { title: "Alert" },
|
||||
};
|
||||
const result = parsePluginApprovalRequested(minimal);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.kind).toBe("plugin");
|
||||
expect(result!.pluginTitle).toBe("Alert");
|
||||
expect(result!.pluginDescription).toBeNull();
|
||||
expect(result!.pluginSeverity).toBeNull();
|
||||
expect(result!.pluginId).toBeNull();
|
||||
expect(result!.request.agentId).toBeNull();
|
||||
expect(result!.request.sessionKey).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -11,7 +11,12 @@ export type ExecApprovalRequestPayload = {
|
|||
|
||||
export type ExecApprovalRequest = {
|
||||
id: string;
|
||||
kind: "exec" | "plugin";
|
||||
request: ExecApprovalRequestPayload;
|
||||
pluginTitle?: string;
|
||||
pluginDescription?: string | null;
|
||||
pluginSeverity?: string | null;
|
||||
pluginId?: string | null;
|
||||
createdAtMs: number;
|
||||
expiresAtMs: number;
|
||||
};
|
||||
|
|
@ -47,6 +52,7 @@ export function parseExecApprovalRequested(payload: unknown): ExecApprovalReques
|
|||
}
|
||||
return {
|
||||
id,
|
||||
kind: "exec",
|
||||
request: {
|
||||
command,
|
||||
cwd: typeof request.cwd === "string" ? request.cwd : null,
|
||||
|
|
@ -78,6 +84,46 @@ export function parseExecApprovalResolved(payload: unknown): ExecApprovalResolve
|
|||
};
|
||||
}
|
||||
|
||||
export function parsePluginApprovalRequested(payload: unknown): ExecApprovalRequest | null {
|
||||
if (!isRecord(payload)) {
|
||||
return null;
|
||||
}
|
||||
const id = typeof payload.id === "string" ? payload.id.trim() : "";
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
const createdAtMs = typeof payload.createdAtMs === "number" ? payload.createdAtMs : 0;
|
||||
const expiresAtMs = typeof payload.expiresAtMs === "number" ? payload.expiresAtMs : 0;
|
||||
if (!createdAtMs || !expiresAtMs) {
|
||||
return null;
|
||||
}
|
||||
// title, description, severity, pluginId, agentId, sessionKey live inside payload.request
|
||||
const request = isRecord(payload.request) ? payload.request : {};
|
||||
const title = typeof request.title === "string" ? request.title.trim() : "";
|
||||
if (!title) {
|
||||
return null;
|
||||
}
|
||||
const description = typeof request.description === "string" ? request.description : null;
|
||||
const severity = typeof request.severity === "string" ? request.severity : null;
|
||||
const pluginId = typeof request.pluginId === "string" ? request.pluginId : null;
|
||||
|
||||
return {
|
||||
id,
|
||||
kind: "plugin",
|
||||
request: {
|
||||
command: title,
|
||||
agentId: typeof request.agentId === "string" ? request.agentId : null,
|
||||
sessionKey: typeof request.sessionKey === "string" ? request.sessionKey : null,
|
||||
},
|
||||
pluginTitle: title,
|
||||
pluginDescription: description,
|
||||
pluginSeverity: severity,
|
||||
pluginId,
|
||||
createdAtMs,
|
||||
expiresAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
export function pruneExecApprovalQueue(queue: ExecApprovalRequest[]): ExecApprovalRequest[] {
|
||||
const now = Date.now();
|
||||
return queue.filter((entry) => entry.expiresAtMs > now);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { html, nothing } from "lit";
|
||||
import type { AppViewState } from "../app-view-state.ts";
|
||||
import type {
|
||||
ExecApprovalRequest,
|
||||
ExecApprovalRequestPayload,
|
||||
} from "../controllers/exec-approval.ts";
|
||||
|
||||
function formatRemaining(ms: number): string {
|
||||
const remaining = Math.max(0, ms);
|
||||
|
|
@ -22,6 +26,37 @@ function renderMetaRow(label: string, value?: string | null) {
|
|||
return html`<div class="exec-approval-meta-row"><span>${label}</span><span>${value}</span></div>`;
|
||||
}
|
||||
|
||||
function renderExecBody(request: ExecApprovalRequestPayload) {
|
||||
return html`
|
||||
<div class="exec-approval-command mono">${request.command}</div>
|
||||
<div class="exec-approval-meta">
|
||||
${renderMetaRow("Host", request.host)}
|
||||
${renderMetaRow("Agent", request.agentId)}
|
||||
${renderMetaRow("Session", request.sessionKey)}
|
||||
${renderMetaRow("CWD", request.cwd)}
|
||||
${renderMetaRow("Resolved", request.resolvedPath)}
|
||||
${renderMetaRow("Security", request.security)}
|
||||
${renderMetaRow("Ask", request.ask)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPluginBody(active: ExecApprovalRequest) {
|
||||
return html`
|
||||
${
|
||||
active.pluginDescription
|
||||
? html`<pre class="exec-approval-command mono" style="white-space:pre-wrap">${active.pluginDescription}</pre>`
|
||||
: nothing
|
||||
}
|
||||
<div class="exec-approval-meta">
|
||||
${renderMetaRow("Severity", active.pluginSeverity)}
|
||||
${renderMetaRow("Plugin", active.pluginId)}
|
||||
${renderMetaRow("Agent", active.request.agentId)}
|
||||
${renderMetaRow("Session", active.request.sessionKey)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderExecApprovalPrompt(state: AppViewState) {
|
||||
const active = state.execApprovalQueue[0];
|
||||
if (!active) {
|
||||
|
|
@ -31,28 +66,28 @@ export function renderExecApprovalPrompt(state: AppViewState) {
|
|||
const remainingMs = active.expiresAtMs - Date.now();
|
||||
const remaining = remainingMs > 0 ? `expires in ${formatRemaining(remainingMs)}` : "expired";
|
||||
const queueCount = state.execApprovalQueue.length;
|
||||
const isPlugin = active.kind === "plugin";
|
||||
const title = isPlugin
|
||||
? (active.pluginTitle ?? "Plugin approval needed")
|
||||
: "Exec approval needed";
|
||||
return html`
|
||||
<div class="exec-approval-overlay" role="dialog" aria-live="polite">
|
||||
<div class="exec-approval-card">
|
||||
<div class="exec-approval-header">
|
||||
<div>
|
||||
<div class="exec-approval-title">Exec approval needed</div>
|
||||
<div class="exec-approval-title">${title}</div>
|
||||
<div class="exec-approval-sub">${remaining}</div>
|
||||
</div>
|
||||
${queueCount > 1
|
||||
? html`<div class="exec-approval-queue">${queueCount} pending</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
<div class="exec-approval-command mono">${request.command}</div>
|
||||
<div class="exec-approval-meta">
|
||||
${renderMetaRow("Host", request.host)} ${renderMetaRow("Agent", request.agentId)}
|
||||
${renderMetaRow("Session", request.sessionKey)} ${renderMetaRow("CWD", request.cwd)}
|
||||
${renderMetaRow("Resolved", request.resolvedPath)}
|
||||
${renderMetaRow("Security", request.security)} ${renderMetaRow("Ask", request.ask)}
|
||||
</div>
|
||||
${state.execApprovalError
|
||||
? html`<div class="exec-approval-error">${state.execApprovalError}</div>`
|
||||
: nothing}
|
||||
${isPlugin ? renderPluginBody(active) : renderExecBody(request)}
|
||||
${
|
||||
state.execApprovalError
|
||||
? html`<div class="exec-approval-error">${state.execApprovalError}</div>`
|
||||
: nothing
|
||||
}
|
||||
<div class="exec-approval-actions">
|
||||
<button
|
||||
class="btn primary"
|
||||
|
|
|
|||
Loading…
Reference in New Issue