Refactor: centralize native approval lifecycle assembly (#62135)

Merged via squash.

Prepared head SHA: b7c20a7398
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana
2026-04-07 14:40:26 -04:00
committed by GitHub
parent 4108901932
commit d78512b09d
128 changed files with 8839 additions and 3995 deletions

View File

@@ -87,8 +87,12 @@ Docs: https://docs.openclaw.ai
- Providers/Mistral: send `reasoning_effort` for `mistral/mistral-small-latest` (Mistral Small 4) with thinking-level mapping, and mark the catalog entry as reasoning-capable so adjustable reasoning matches Mistrals Chat Completions API. (#62162) Thanks @neeravmakwana.
- OpenAI TTS/Groq: send `wav` to Groq-compatible speech endpoints, honor explicit `responseFormat` overrides on OpenAI-compatible paths, and only mark voice-note output as voice-compatible when the actual format is `opus`. (#62233) Thanks @neeravmakwana.
- BlueBubbles/network: respect explicit private-network opt-out for loopback and private `serverUrl` values across account resolution, status probes, monitor startup, and attachment downloads, while keeping public-host attachment hostname pinning intact. (#59373) Thanks @jpreagan.
<<<<<<< HEAD
- Agents/heartbeat: keep heartbeat runs pinned to the main session so active subagent transcripts are not overwritten by heartbeat status messages. (#61803) thanks @100yenadmin.
- Agents/compaction: stop compaction-wait aborts from re-entering prompt failover and replaying completed tool turns. (#62600) Thanks @i-dentifier.
=======
- Approvals/runtime: move native approval lifecycle assembly into shared core bootstrap/runtime seams driven by channel capabilities and runtime contexts, and remove the legacy bundled approval fallback wiring. (#62135) Thanks @gumadeiras.
>>>>>>> 367f6afaf1 (Approvals: finish capability cutover and Matrix parity)
## 2026.4.5

View File

@@ -1,2 +1,2 @@
5e28885aeddb1c2e73040c88b503d584bbcd871c6941fd1ebf7f22ceac3477a6 plugin-sdk-api-baseline.json
c8bbc54b51588b6b9aecabb3fcf02ecb69867c8ac527b65d5ec3bc5c6288057a plugin-sdk-api-baseline.jsonl
7abdd7f9977c44bb8799f6c1047aa2a025217bbdc46c42329e46796e9d08b02a plugin-sdk-api-baseline.json
aca10bdd74bae01a8a2210c745ac2a0583b83ff8035aa2764b817967cb3a0b02 plugin-sdk-api-baseline.jsonl

View File

@@ -880,7 +880,8 @@ See [Pairing](/channels/pairing) for the shared DM pairing flow and storage layo
## Exec approvals
Matrix can act as an exec approval client for a Matrix account.
Matrix can act as a native approval client for a Matrix account. The native
DM/channel routing knobs still live under exec approval config:
- `channels.matrix.execApprovals.enabled`
- `channels.matrix.execApprovals.approvers` (optional; falls back to `channels.matrix.dm.allowFrom`)
@@ -888,13 +889,14 @@ Matrix can act as an exec approval client for a Matrix account.
- `channels.matrix.execApprovals.agentFilter`
- `channels.matrix.execApprovals.sessionFilter`
Approvers must be Matrix user IDs such as `@owner:example.org`. Matrix auto-enables native exec approvals when `enabled` is unset or `"auto"` and at least one approver can be resolved, either from `execApprovals.approvers` or from `channels.matrix.dm.allowFrom`. Set `enabled: false` to disable Matrix as a native approval client explicitly. Approval requests otherwise fall back to other configured approval routes or the exec approval fallback policy.
Approvers must be Matrix user IDs such as `@owner:example.org`. Matrix auto-enables native approvals when `enabled` is unset or `"auto"` and at least one approver can be resolved. Exec approvals use `execApprovals.approvers` first and can fall back to `channels.matrix.dm.allowFrom`. Plugin approvals authorize through `channels.matrix.dm.allowFrom`. Set `enabled: false` to disable Matrix as a native approval client explicitly. Approval requests otherwise fall back to other configured approval routes or the approval fallback policy.
Native Matrix routing is exec-only today:
Matrix native routing now supports both approval kinds:
- `channels.matrix.execApprovals.*` controls native DM/channel routing for exec approvals only.
- Plugin approvals still use shared same-chat `/approve` plus any configured `approvals.plugin` forwarding.
- Matrix can still reuse `channels.matrix.dm.allowFrom` for plugin-approval authorization when it can infer approvers safely, but it does not expose a separate native plugin-approval DM/channel fanout path.
- `channels.matrix.execApprovals.*` controls the native DM/channel fanout mode for Matrix approval prompts.
- Exec approvals use the exec approver set from `execApprovals.approvers` or `channels.matrix.dm.allowFrom`.
- Plugin approvals use the Matrix DM allowlist from `channels.matrix.dm.allowFrom`.
- Matrix reaction shortcuts and message updates apply to both exec and plugin approvals.
Delivery rules:
@@ -910,9 +912,9 @@ Matrix approval prompts seed reaction shortcuts on the primary approval message:
Approvers can react on that message or use the fallback slash commands: `/approve <id> allow-once`, `/approve <id> allow-always`, or `/approve <id> deny`.
Only resolved approvers can approve or deny. Channel delivery includes the command text, so only enable `channel` or `both` in trusted rooms.
Only resolved approvers can approve or deny. For exec approvals, channel delivery includes the command text, so only enable `channel` or `both` in trusted rooms.
Matrix approval prompts reuse the shared core approval planner. The Matrix-specific native surface is transport only for exec approvals: room/DM routing and message send/update/delete behavior.
Matrix approval prompts reuse the shared core approval planner. The Matrix-specific native surface handles room/DM routing, reactions, and message send/update/delete behavior for both exec and plugin approvals.
Per-account override:

View File

@@ -1134,6 +1134,7 @@ authoring plugins:
`openclaw/plugin-sdk/channel-config-schema`,
`openclaw/plugin-sdk/telegram-command-config`,
`openclaw/plugin-sdk/channel-policy`,
`openclaw/plugin-sdk/approval-handler-runtime`,
`openclaw/plugin-sdk/approval-runtime`,
`openclaw/plugin-sdk/config-runtime`,
`openclaw/plugin-sdk/infra-runtime`,
@@ -1152,9 +1153,9 @@ authoring plugins:
assistant-visible-text stripping, markdown render/chunking helpers, redaction
helpers, directive-tag helpers, and safe-text utilities.
- Approval-specific channel seams should prefer one `approvalCapability`
contract on the plugin. Core then reads approval auth, delivery, render, and
native-routing behavior through that one capability instead of mixing
approval behavior into unrelated plugin fields.
contract on the plugin. Core then reads approval auth, delivery, render,
native-routing, and lazy native-handler behavior through that one capability
instead of mixing approval behavior into unrelated plugin fields.
- `openclaw/plugin-sdk/channel-runtime` is deprecated and remains only as a
compatibility shim for older plugins. New code should import the narrower
generic primitives instead, and repo code should not add new imports of the

View File

@@ -60,22 +60,34 @@ Most channel plugins do not need approval-specific code.
- Core owns same-chat `/approve`, shared approval button payloads, and generic fallback delivery.
- Prefer one `approvalCapability` object on the channel plugin when the channel needs approval-specific behavior.
- `ChannelPlugin.approvals` is removed. Put approval delivery/native/render/auth facts on `approvalCapability`.
- `plugin.auth` is login/logout only; core no longer reads approval auth hooks from that object.
- `approvalCapability.authorizeActorAction` and `approvalCapability.getActionAvailabilityState` are the canonical approval-auth seam.
- If your channel exposes native exec approvals, implement `approvalCapability.getActionAvailabilityState` even when the native transport lives entirely under `approvalCapability.native`. Core uses that availability hook to distinguish `enabled` vs `disabled`, decide whether the initiating channel supports native approvals, and include the channel in native-client fallback guidance.
- Use `approvalCapability.getActionAvailabilityState` for same-chat approval auth availability.
- If your channel exposes native exec approvals, use `approvalCapability.getExecInitiatingSurfaceState` for the initiating-surface/native-client state when it differs from same-chat approval auth. Core uses that exec-specific hook to distinguish `enabled` vs `disabled`, decide whether the initiating channel supports native exec approvals, and include the channel in native-client fallback guidance. `createApproverRestrictedNativeApprovalCapability(...)` fills this in for the common case.
- Use `outbound.shouldSuppressLocalPayloadPrompt` or `outbound.beforeDeliverPayload` for channel-specific payload lifecycle behavior such as hiding duplicate local approval prompts or sending typing indicators before delivery.
- Use `approvalCapability.delivery` only for native approval routing or fallback suppression.
- Use `approvalCapability.nativeRuntime` for channel-owned native approval facts. Keep it lazy on hot channel entrypoints with `createLazyChannelApprovalNativeRuntimeAdapter(...)`, which can import your runtime module on demand while still letting core assemble the approval lifecycle.
- Use `approvalCapability.render` only when a channel truly needs custom approval payloads instead of the shared renderer.
- Use `approvalCapability.describeExecApprovalSetup` when the channel wants the disabled-path reply to explain the exact config knobs needed to enable native exec approvals. The hook receives `{ channel, channelLabel, accountId }`; named-account channels should render account-scoped paths such as `channels.<channel>.accounts.<id>.execApprovals.*` instead of top-level defaults.
- If a channel can infer stable owner-like DM identities from existing config, use `createResolvedApproverActionAuthAdapter` from `openclaw/plugin-sdk/approval-runtime` to restrict same-chat `/approve` without adding approval-specific core logic.
- If a channel needs native approval delivery, keep channel code focused on target normalization and transport hooks. Use `createChannelExecApprovalProfile`, `createChannelNativeOriginTargetResolver`, `createChannelApproverDmTargetResolver`, `createApproverRestrictedNativeApprovalCapability`, and `createChannelNativeApprovalRuntime` from `openclaw/plugin-sdk/approval-runtime` so core owns request filtering, routing, dedupe, expiry, and gateway subscription.
- If a channel needs native approval delivery, keep channel code focused on target normalization plus transport/presentation facts. Use `createChannelExecApprovalProfile`, `createChannelNativeOriginTargetResolver`, `createChannelApproverDmTargetResolver`, and `createApproverRestrictedNativeApprovalCapability` from `openclaw/plugin-sdk/approval-runtime`. Put the channel-specific facts behind `approvalCapability.nativeRuntime`, ideally via `createChannelApprovalNativeRuntimeAdapter(...)` or `createLazyChannelApprovalNativeRuntimeAdapter(...)`, so core can assemble the handler and own request filtering, routing, dedupe, expiry, gateway subscription, and routed-elsewhere notices. `nativeRuntime` is split into a few smaller seams:
- `availability` — whether the account is configured and whether a request should be handled
- `presentation` — map the shared approval view model into pending/resolved/expired native payloads or final actions
- `transport` — prepare targets plus send/update/delete native approval messages
- `interactions` — optional bind/unbind/clear-action hooks for native buttons or reactions
- `observe` — optional delivery diagnostics hooks
- If the channel needs runtime-owned objects such as a client, token, Bolt app, or webhook receiver, register them through `openclaw/plugin-sdk/channel-runtime-context`. The generic runtime-context registry lets core bootstrap capability-driven handlers from channel startup state without adding approval-specific wrapper glue.
- Reach for the lower-level `createChannelApprovalHandler` or `createChannelNativeApprovalRuntime` only when the capability-driven seam is not expressive enough yet.
- Native approval channels must route both `accountId` and `approvalKind` through those helpers. `accountId` keeps multi-account approval policy scoped to the right bot account, and `approvalKind` keeps exec vs plugin approval behavior available to the channel without hardcoded branches in core.
- Core now owns approval reroute notices too. Channel plugins should not send their own "approval went to DMs / another channel" follow-up messages from `createChannelNativeApprovalRuntime`; instead, expose accurate origin + approver-DM routing through the shared approval capability helpers and let core aggregate actual deliveries before posting any notice back to the initiating chat.
- Preserve the delivered approval id kind end-to-end. Native clients should not
guess or rewrite exec vs plugin approval routing from channel-local state.
- Different approval kinds can intentionally expose different native surfaces.
Current bundled examples:
- Slack keeps native approval routing available for both exec and plugin ids.
- Matrix keeps native DM/channel routing for exec approvals only and leaves
plugin approvals on the shared same-chat `/approve` path.
- Matrix keeps the same native DM/channel routing and reaction UX for exec
and plugin approvals, while still letting auth differ by approval kind.
- `createApproverRestrictedNativeApprovalAdapter` still exists as a compatibility wrapper, but new code should prefer the capability builder and expose `approvalCapability` on the plugin.
For hot channel entrypoints, prefer the narrower runtime subpaths when you only
@@ -84,8 +96,10 @@ need one part of that family:
- `openclaw/plugin-sdk/approval-auth-runtime`
- `openclaw/plugin-sdk/approval-client-runtime`
- `openclaw/plugin-sdk/approval-delivery-runtime`
- `openclaw/plugin-sdk/approval-handler-runtime`
- `openclaw/plugin-sdk/approval-native-runtime`
- `openclaw/plugin-sdk/approval-reply-runtime`
- `openclaw/plugin-sdk/channel-runtime-context`
Likewise, prefer `openclaw/plugin-sdk/setup-runtime`,
`openclaw/plugin-sdk/setup-adapter-runtime`,

View File

@@ -67,6 +67,32 @@ Current bundled provider examples:
## How to migrate
<Steps>
<Step title="Migrate approval-native handlers to capability facts">
Approval-capable channel plugins now expose native approval behavior through
`approvalCapability.nativeRuntime` plus the shared runtime-context registry.
Key changes:
- Replace `approvalCapability.handler.loadRuntime(...)` with
`approvalCapability.nativeRuntime`
- Move approval-specific auth/delivery off legacy `plugin.auth` /
`plugin.approvals` wiring and onto `approvalCapability`
- `ChannelPlugin.approvals` has been removed from the public channel-plugin
contract; move delivery/native/render fields onto `approvalCapability`
- `plugin.auth` remains for channel login/logout flows only; approval auth
hooks there are no longer read by core
- Register channel-owned runtime objects such as clients, tokens, or Bolt
apps through `openclaw/plugin-sdk/channel-runtime-context`
- Do not send plugin-owned reroute notices from native approval handlers;
core now owns routed-elsewhere notices from actual delivery results
- When passing `channelRuntime` into `createChannelManager(...)`, provide a
real `createPluginRuntime().channel` surface. Partial stubs are rejected.
See `/plugins/sdk-channel-plugins` for the current approval capability
layout.
</Step>
<Step title="Audit Windows wrapper fallback behavior">
If your plugin uses `openclaw/plugin-sdk/windows-spawn`, unresolved Windows
`.cmd`/`.bat` wrappers now fail closed unless you explicitly pass
@@ -201,8 +227,10 @@ Current bundled provider examples:
| `plugin-sdk/approval-auth-runtime` | Approval auth helpers | Approver resolution, same-chat action auth |
| `plugin-sdk/approval-client-runtime` | Approval client helpers | Native exec approval profile/filter helpers |
| `plugin-sdk/approval-delivery-runtime` | Approval delivery helpers | Native approval capability/delivery adapters |
| `plugin-sdk/approval-handler-runtime` | Approval handler helpers | Shared approval handler runtime helpers, including capability-driven native approval loading |
| `plugin-sdk/approval-native-runtime` | Approval target helpers | Native approval target/account binding helpers |
| `plugin-sdk/approval-reply-runtime` | Approval reply helpers | Exec/plugin approval reply payload helpers |
| `plugin-sdk/channel-runtime-context` | Channel runtime-context helpers | Generic channel runtime-context register/get/watch helpers |
| `plugin-sdk/security-runtime` | Security helpers | Shared trust, DM gating, external-content, and secret-collection helpers |
| `plugin-sdk/ssrf-policy` | SSRF policy helpers | Host allowlist and private-network policy helpers |
| `plugin-sdk/ssrf-runtime` | SSRF runtime helpers | Pinned-dispatcher, guarded fetch, SSRF policy helpers |

View File

@@ -151,6 +151,7 @@ explicitly promotes one as public.
| `plugin-sdk/approval-auth-runtime` | Approver resolution and same-chat action-auth helpers |
| `plugin-sdk/approval-client-runtime` | Native exec approval profile/filter helpers |
| `plugin-sdk/approval-delivery-runtime` | Native approval capability/delivery adapters |
| `plugin-sdk/approval-handler-runtime` | Shared approval handler runtime helpers, including capability-driven native approval loading |
| `plugin-sdk/approval-native-runtime` | Native approval target + account-binding helpers |
| `plugin-sdk/approval-reply-runtime` | Exec/plugin approval reply payload helpers |
| `plugin-sdk/command-auth-native` | Native command auth + native session-target helpers |
@@ -172,6 +173,7 @@ explicitly promotes one as public.
| --- | --- |
| `plugin-sdk/runtime` | Broad runtime/logging/backup/plugin-install helpers |
| `plugin-sdk/runtime-env` | Narrow runtime env, logger, timeout, retry, and backoff helpers |
| `plugin-sdk/channel-runtime-context` | Generic channel runtime-context registration and lookup helpers |
| `plugin-sdk/runtime-store` | `createPluginRuntimeStore` |
| `plugin-sdk/plugin-runtime` | Shared plugin command/hook/http/interactive helpers |
| `plugin-sdk/hook-runtime` | Shared webhook/internal hook pipeline helpers |

View File

@@ -557,8 +557,8 @@ Shared behavior:
- Slack approvers can be explicit (`execApprovals.approvers`) or inferred from `commands.ownerAllowFrom`
- Slack native buttons preserve approval id kind, so `plugin:` ids can resolve plugin approvals
without a second Slack-local fallback layer
- Matrix native DM/channel routing is exec-only; Matrix plugin approvals stay on the shared
same-chat `/approve` and optional `approvals.plugin` forwarding paths
- Matrix native DM/channel routing and reaction shortcuts handle both exec and plugin approvals;
plugin authorization still comes from `channels.matrix.dm.allowFrom`
- the requester does not need to be an approver
- the originating chat can approve directly with `/approve` when that chat already supports commands and replies
- native Discord approval buttons route by approval id kind: `plugin:` ids go

View File

@@ -0,0 +1,45 @@
import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approval-native-runtime";
import { describe, expect, it } from "vitest";
import { discordApprovalNativeRuntime } from "./approval-handler.runtime.js";
describe("discordApprovalNativeRuntime", () => {
it("routes origin approval updates to the Discord thread channel when threadId is present", async () => {
const prepared = await discordApprovalNativeRuntime.transport.prepareTarget({
cfg: {} as never,
accountId: "main",
context: {
token: "discord-token",
config: {} as never,
},
plannedTarget: {
surface: "origin",
reason: "preferred",
target: {
to: "123456789",
threadId: "777888999",
},
},
request: {
id: "req-1",
request: {
command: "hostname",
},
createdAtMs: 0,
expiresAtMs: 1_000,
},
approvalKind: "exec",
view: {} as never,
pendingPayload: {} as never,
});
expect(prepared).toEqual({
dedupeKey: buildChannelApprovalNativeTargetKey({
to: "123456789",
threadId: "777888999",
}),
target: {
discordChannelId: "777888999",
},
});
});
});

View File

@@ -0,0 +1,626 @@
import {
Button,
Row,
Separator,
TextDisplay,
serializePayload,
type MessagePayloadObject,
type TopLevelComponents,
} from "@buape/carbon";
import { ButtonStyle, Routes } from "discord-api-types/v10";
import type {
ChannelApprovalCapabilityHandlerContext,
ExecApprovalExpiredView,
ExecApprovalPendingView,
ExecApprovalResolvedView,
PendingApprovalView,
PluginApprovalExpiredView,
PluginApprovalPendingView,
PluginApprovalResolvedView,
} from "openclaw/plugin-sdk/approval-handler-runtime";
import { createChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approval-native-runtime";
import type { DiscordExecApprovalConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type {
ExecApprovalActionDescriptor,
ExecApprovalDecision,
} from "openclaw/plugin-sdk/infra-runtime";
import { logDebug, logError } from "openclaw/plugin-sdk/text-runtime";
import { shouldHandleDiscordApprovalRequest } from "./approval-native.js";
import { isDiscordExecApprovalClientEnabled } from "./exec-approvals.js";
import { createDiscordClient, stripUndefinedFields } from "./send.shared.js";
import { DiscordUiContainer } from "./ui.js";
type PendingApproval = {
discordMessageId: string;
discordChannelId: string;
};
type DiscordPendingDelivery = {
body: ReturnType<typeof stripUndefinedFields>;
};
type PreparedDeliveryTarget = {
discordChannelId: string;
recipientUserId?: string;
};
export type DiscordApprovalHandlerContext = {
token: string;
config: DiscordExecApprovalConfig;
};
function resolveHandlerContext(params: ChannelApprovalCapabilityHandlerContext): {
accountId: string;
context: DiscordApprovalHandlerContext;
} | null {
const context = params.context as DiscordApprovalHandlerContext | undefined;
const accountId = params.accountId?.trim() || "";
if (!context?.token || !accountId) {
return null;
}
return { accountId, context };
}
class ExecApprovalContainer extends DiscordUiContainer {
constructor(params: {
cfg: OpenClawConfig;
accountId: string;
title: string;
description?: string;
commandPreview: string;
commandSecondaryPreview?: string | null;
metadataLines?: string[];
actionRow?: Row<Button>;
footer?: string;
accentColor?: string;
}) {
const components: Array<TextDisplay | Separator | Row<Button>> = [
new TextDisplay(`## ${params.title}`),
];
if (params.description) {
components.push(new TextDisplay(params.description));
}
components.push(new Separator({ divider: true, spacing: "small" }));
components.push(new TextDisplay(`### Command\n\`\`\`\n${params.commandPreview}\n\`\`\``));
if (params.commandSecondaryPreview) {
components.push(
new TextDisplay(`### Shell Preview\n\`\`\`\n${params.commandSecondaryPreview}\n\`\`\``),
);
}
if (params.metadataLines?.length) {
components.push(new TextDisplay(params.metadataLines.join("\n")));
}
if (params.actionRow) {
components.push(params.actionRow);
}
if (params.footer) {
components.push(new Separator({ divider: false, spacing: "small" }));
components.push(new TextDisplay(`-# ${params.footer}`));
}
super({
cfg: params.cfg,
accountId: params.accountId,
components,
accentColor: params.accentColor,
});
}
}
class ExecApprovalActionButton extends Button {
customId: string;
label: string;
style: ButtonStyle;
constructor(params: { approvalId: string; descriptor: ExecApprovalActionDescriptor }) {
super();
this.customId = buildExecApprovalCustomId(params.approvalId, params.descriptor.decision);
this.label = params.descriptor.label;
this.style =
params.descriptor.style === "success"
? ButtonStyle.Success
: params.descriptor.style === "primary"
? ButtonStyle.Primary
: params.descriptor.style === "danger"
? ButtonStyle.Danger
: ButtonStyle.Secondary;
}
}
class ExecApprovalActionRow extends Row<Button> {
constructor(params: { approvalId: string; actions: readonly ExecApprovalActionDescriptor[] }) {
super(
params.actions.map(
(descriptor) => new ExecApprovalActionButton({ approvalId: params.approvalId, descriptor }),
),
);
}
}
function createApprovalActionRow(view: PendingApprovalView): Row<Button> {
return new ExecApprovalActionRow({
approvalId: view.approvalId,
actions: view.actions,
});
}
function buildApprovalMetadataLines(
metadata: readonly { label: string; value: string }[],
): string[] {
return metadata.map((item) => `- ${item.label}: ${item.value}`);
}
function buildExecApprovalPayload(container: DiscordUiContainer): MessagePayloadObject {
const components: TopLevelComponents[] = [container];
return { components };
}
function formatCommandPreview(commandText: string, maxChars: number): string {
const commandRaw =
commandText.length > maxChars ? `${commandText.slice(0, maxChars)}...` : commandText;
return commandRaw.replace(/`/g, "\u200b`");
}
function formatOptionalCommandPreview(
commandText: string | null | undefined,
maxChars: number,
): string | null {
if (!commandText) {
return null;
}
return formatCommandPreview(commandText, maxChars);
}
function resolveCommandPreviews(
commandText: string,
commandPreview: string | null | undefined,
maxChars: number,
secondaryMaxChars: number,
): { commandPreview: string; commandSecondaryPreview: string | null } {
return {
commandPreview: formatCommandPreview(commandText, maxChars),
commandSecondaryPreview: formatOptionalCommandPreview(commandPreview, secondaryMaxChars),
};
}
function createExecApprovalRequestContainer(params: {
view: ExecApprovalPendingView;
cfg: OpenClawConfig;
accountId: string;
actionRow?: Row<Button>;
}): ExecApprovalContainer {
const { commandPreview, commandSecondaryPreview } = resolveCommandPreviews(
params.view.commandText,
params.view.commandPreview,
1000,
500,
);
const expiresAtSeconds = Math.max(0, Math.floor(params.view.expiresAtMs / 1000));
return new ExecApprovalContainer({
cfg: params.cfg,
accountId: params.accountId,
title: "Exec Approval Required",
description: "A command needs your approval.",
commandPreview,
commandSecondaryPreview,
metadataLines: buildApprovalMetadataLines(params.view.metadata),
actionRow: params.actionRow,
footer: `Expires <t:${expiresAtSeconds}:R> · ID: ${params.view.approvalId}`,
accentColor: "#FFA500",
});
}
function createPluginApprovalRequestContainer(params: {
view: PluginApprovalPendingView;
cfg: OpenClawConfig;
accountId: string;
actionRow?: Row<Button>;
}): ExecApprovalContainer {
const expiresAtSeconds = Math.max(0, Math.floor(params.view.expiresAtMs / 1000));
const severity = params.view.severity;
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.view.title, 700),
commandSecondaryPreview: formatOptionalCommandPreview(params.view.description, 1000),
metadataLines: buildApprovalMetadataLines(params.view.metadata),
actionRow: params.actionRow,
footer: `Expires <t:${expiresAtSeconds}:R> · ID: ${params.view.approvalId}`,
accentColor,
});
}
function createExecResolvedContainer(params: {
view: ExecApprovalResolvedView;
cfg: OpenClawConfig;
accountId: string;
}): ExecApprovalContainer {
const { commandPreview, commandSecondaryPreview } = resolveCommandPreviews(
params.view.commandText,
params.view.commandPreview,
500,
300,
);
const decisionLabel =
params.view.decision === "allow-once"
? "Allowed (once)"
: params.view.decision === "allow-always"
? "Allowed (always)"
: "Denied";
const accentColor =
params.view.decision === "deny"
? "#ED4245"
: params.view.decision === "allow-always"
? "#5865F2"
: "#57F287";
return new ExecApprovalContainer({
cfg: params.cfg,
accountId: params.accountId,
title: `Exec Approval: ${decisionLabel}`,
description: params.view.resolvedBy ? `Resolved by ${params.view.resolvedBy}` : "Resolved",
commandPreview,
commandSecondaryPreview,
metadataLines: buildApprovalMetadataLines(params.view.metadata),
footer: `ID: ${params.view.approvalId}`,
accentColor,
});
}
function createPluginResolvedContainer(params: {
view: PluginApprovalResolvedView;
cfg: OpenClawConfig;
accountId: string;
}): ExecApprovalContainer {
const decisionLabel =
params.view.decision === "allow-once"
? "Allowed (once)"
: params.view.decision === "allow-always"
? "Allowed (always)"
: "Denied";
const accentColor =
params.view.decision === "deny"
? "#ED4245"
: params.view.decision === "allow-always"
? "#5865F2"
: "#57F287";
return new ExecApprovalContainer({
cfg: params.cfg,
accountId: params.accountId,
title: `Plugin Approval: ${decisionLabel}`,
description: params.view.resolvedBy ? `Resolved by ${params.view.resolvedBy}` : "Resolved",
commandPreview: formatCommandPreview(params.view.title, 700),
commandSecondaryPreview: formatOptionalCommandPreview(params.view.description, 1000),
metadataLines: buildApprovalMetadataLines(params.view.metadata),
footer: `ID: ${params.view.approvalId}`,
accentColor,
});
}
function createExecExpiredContainer(params: {
view: ExecApprovalExpiredView;
cfg: OpenClawConfig;
accountId: string;
}): ExecApprovalContainer {
const { commandPreview, commandSecondaryPreview } = resolveCommandPreviews(
params.view.commandText,
params.view.commandPreview,
500,
300,
);
return new ExecApprovalContainer({
cfg: params.cfg,
accountId: params.accountId,
title: "Exec Approval: Expired",
description: "This approval request has expired.",
commandPreview,
commandSecondaryPreview,
metadataLines: buildApprovalMetadataLines(params.view.metadata),
footer: `ID: ${params.view.approvalId}`,
accentColor: "#99AAB5",
});
}
function createPluginExpiredContainer(params: {
view: PluginApprovalExpiredView;
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.view.title, 700),
commandSecondaryPreview: formatOptionalCommandPreview(params.view.description, 1000),
metadataLines: buildApprovalMetadataLines(params.view.metadata),
footer: `ID: ${params.view.approvalId}`,
accentColor: "#99AAB5",
});
}
export function buildExecApprovalCustomId(
approvalId: string,
action: ExecApprovalDecision,
): string {
return [`execapproval:id=${encodeURIComponent(approvalId)}`, `action=${action}`].join(";");
}
async function updateMessage(params: {
cfg: OpenClawConfig;
accountId: string;
token: string;
channelId: string;
messageId: string;
container: DiscordUiContainer;
}): Promise<void> {
try {
const { rest, request: discordRequest } = createDiscordClient(
{ token: params.token, accountId: params.accountId },
params.cfg,
);
const payload = buildExecApprovalPayload(params.container);
await discordRequest(
() =>
rest.patch(Routes.channelMessage(params.channelId, params.messageId), {
body: stripUndefinedFields(serializePayload(payload)),
}),
"update-approval",
);
} catch (err) {
logError(`discord approvals: failed to update message: ${String(err)}`);
}
}
async function finalizeMessage(params: {
cfg: OpenClawConfig;
accountId: string;
token: string;
cleanupAfterResolve?: boolean;
channelId: string;
messageId: string;
container: DiscordUiContainer;
}): Promise<void> {
if (!params.cleanupAfterResolve) {
await updateMessage(params);
return;
}
try {
const { rest, request: discordRequest } = createDiscordClient(
{ token: params.token, accountId: params.accountId },
params.cfg,
);
await discordRequest(
() => rest.delete(Routes.channelMessage(params.channelId, params.messageId)) as Promise<void>,
"delete-approval",
);
} catch (err) {
logError(`discord approvals: failed to delete message: ${String(err)}`);
await updateMessage(params);
}
}
export const discordApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter<
DiscordPendingDelivery,
PreparedDeliveryTarget,
PendingApproval,
never
>({
eventKinds: ["exec", "plugin"],
resolveApprovalKind: (request) => (request.id.startsWith("plugin:") ? "plugin" : "exec"),
availability: {
isConfigured: (params) => {
const resolved = resolveHandlerContext(params);
return resolved
? isDiscordExecApprovalClientEnabled({
cfg: params.cfg,
accountId: resolved.accountId,
configOverride: resolved.context.config,
})
: false;
},
shouldHandle: (params) => {
const resolved = resolveHandlerContext(params);
return resolved
? shouldHandleDiscordApprovalRequest({
cfg: params.cfg,
accountId: resolved.accountId,
request: params.request,
configOverride: resolved.context.config,
})
: false;
},
},
presentation: {
buildPendingPayload: ({ cfg, accountId, context, view }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return { body: {} };
}
const actionRow = createApprovalActionRow(view);
const container =
view.approvalKind === "plugin"
? createPluginApprovalRequestContainer({
view: view,
cfg,
accountId: resolved.accountId,
actionRow,
})
: createExecApprovalRequestContainer({
view: view,
cfg,
accountId: resolved.accountId,
actionRow,
});
return {
body: stripUndefinedFields(serializePayload(buildExecApprovalPayload(container))),
};
},
buildResolvedResult: ({ cfg, accountId, context, view }) => {
const resolvedContext = resolveHandlerContext({ cfg, accountId, context });
if (!resolvedContext) {
return { kind: "delete" } as const;
}
const container =
view.approvalKind === "plugin"
? createPluginResolvedContainer({
view: view,
cfg,
accountId: resolvedContext.accountId,
})
: createExecResolvedContainer({
view: view,
cfg,
accountId: resolvedContext.accountId,
});
return { kind: "update", payload: container } as const;
},
buildExpiredResult: ({ cfg, accountId, context, view }) => {
const resolvedContext = resolveHandlerContext({ cfg, accountId, context });
if (!resolvedContext) {
return { kind: "delete" } as const;
}
const container =
view.approvalKind === "plugin"
? createPluginExpiredContainer({
view: view,
cfg,
accountId: resolvedContext.accountId,
})
: createExecExpiredContainer({
view: view,
cfg,
accountId: resolvedContext.accountId,
});
return { kind: "update", payload: container } as const;
},
},
transport: {
prepareTarget: async ({ cfg, accountId, context, plannedTarget }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return null;
}
if (plannedTarget.surface === "origin") {
const destinationId =
typeof plannedTarget.target.threadId === "string" &&
plannedTarget.target.threadId.trim().length > 0
? plannedTarget.target.threadId.trim()
: plannedTarget.target.to;
return {
dedupeKey: buildChannelApprovalNativeTargetKey(plannedTarget.target),
target: {
discordChannelId: destinationId,
},
};
}
const { rest, request: discordRequest } = createDiscordClient(
{ token: resolved.context.token, accountId: resolved.accountId },
cfg,
);
const userId = plannedTarget.target.to;
const dmChannel = (await discordRequest(
() =>
rest.post(Routes.userChannels(), {
body: { recipient_id: userId },
}) as Promise<{ id: string }>,
"dm-channel",
)) as { id: string };
if (!dmChannel?.id) {
logError(`discord approvals: failed to create DM for user ${userId}`);
return null;
}
return {
dedupeKey: dmChannel.id,
target: {
discordChannelId: dmChannel.id,
recipientUserId: userId,
},
};
},
deliverPending: async ({
cfg,
accountId,
context,
plannedTarget,
preparedTarget,
pendingPayload,
}) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return null;
}
const { rest, request: discordRequest } = createDiscordClient(
{ token: resolved.context.token, accountId: resolved.accountId },
cfg,
);
const message = (await discordRequest(
() =>
rest.post(Routes.channelMessages(preparedTarget.discordChannelId), {
body: pendingPayload.body,
}) as Promise<{ id: string; channel_id: string }>,
plannedTarget.surface === "origin" ? "send-approval-channel" : "send-approval",
)) as { id: string; channel_id: string };
if (!message?.id) {
if (plannedTarget.surface === "origin") {
logError("discord approvals: failed to send to channel");
} else if (preparedTarget.recipientUserId) {
logError(
`discord approvals: failed to send message to user ${preparedTarget.recipientUserId}`,
);
}
return null;
}
return {
discordMessageId: message.id,
discordChannelId: preparedTarget.discordChannelId,
};
},
updateEntry: async ({ cfg, accountId, context, entry, payload, phase }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return;
}
const container = payload as DiscordUiContainer;
await finalizeMessage({
cfg,
accountId: resolved.accountId,
token: resolved.context.token,
cleanupAfterResolve:
phase === "resolved" ? resolved.context.config.cleanupAfterResolve : false,
channelId: entry.discordChannelId,
messageId: entry.discordMessageId,
container,
});
},
},
observe: {
onDuplicateSkipped: ({ preparedTarget, request }) => {
logDebug(
`discord approvals: skipping duplicate approval ${request.id} for channel ${preparedTarget.dedupeKey}`,
);
},
onDelivered: ({ plannedTarget, preparedTarget, request }) => {
if (plannedTarget.surface === "origin") {
logDebug(
`discord approvals: sent approval ${request.id} to channel ${preparedTarget.target.discordChannelId}`,
);
return;
}
logDebug(`discord approvals: sent approval ${request.id} to user ${plannedTarget.target.to}`);
},
onDeliveryError: ({ error, plannedTarget }) => {
if (plannedTarget.surface === "origin") {
logError(`discord approvals: failed to send to channel: ${String(error)}`);
return;
}
logError(
`discord approvals: failed to notify user ${plannedTarget.target.to}: ${String(error)}`,
);
},
},
});

View File

@@ -220,7 +220,7 @@ describe("createDiscordNativeApprovalAdapter", () => {
},
});
expect(target).toEqual({ to: "123456789" });
expect(target).toEqual({ to: "123456789", threadId: undefined });
});
it("falls back to extracting the channel id from the session key", async () => {
@@ -242,7 +242,55 @@ describe("createDiscordNativeApprovalAdapter", () => {
},
});
expect(target).toEqual({ to: "987654321" });
expect(target).toEqual({ to: "987654321", threadId: undefined });
});
it("preserves explicit turn-source thread ids on origin targets", async () => {
const adapter = createDiscordNativeApprovalAdapter();
const target = await adapter.native?.resolveOriginTarget?.({
cfg: NATIVE_APPROVAL_CFG as never,
accountId: "main",
approvalKind: "plugin",
request: {
id: "abc",
request: {
title: "Plugin approval",
description: "Let plugin proceed",
sessionKey: "agent:main:discord:channel:123456789:thread:777888999",
turnSourceChannel: "discord",
turnSourceTo: "channel:123456789",
turnSourceThreadId: "777888999",
turnSourceAccountId: "main",
},
createdAtMs: 1,
expiresAtMs: 2,
},
});
expect(target).toEqual({ to: "123456789", threadId: "777888999" });
});
it("falls back to extracting thread ids from the session key", async () => {
const adapter = createDiscordNativeApprovalAdapter();
const target = await adapter.native?.resolveOriginTarget?.({
cfg: NATIVE_APPROVAL_CFG as never,
accountId: "main",
approvalKind: "plugin",
request: {
id: "abc",
request: {
title: "Plugin approval",
description: "Let plugin proceed",
sessionKey: "agent:main:discord:channel:987654321:thread:444555666",
},
createdAtMs: 1,
expiresAtMs: 2,
},
});
expect(target).toEqual({ to: "987654321", threadId: "444555666" });
});
it("rejects origin delivery for requests bound to another Discord account", async () => {

View File

@@ -1,3 +1,5 @@
import { createLazyChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
import { resolveApprovalRequestSessionConversation } from "openclaw/plugin-sdk/approval-native-runtime";
import type { DiscordExecApprovalConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
@@ -19,6 +21,8 @@ import {
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
// Legacy export kept for monitor test/support surfaces; native routing now uses
// the shared session-conversation fallback helper instead.
export function extractDiscordChannelId(sessionKey?: string | null): string | null {
if (!sessionKey) {
return null;
@@ -27,6 +31,14 @@ export function extractDiscordChannelId(sessionKey?: string | null): string | nu
return match ? match[1] : null;
}
export function extractDiscordThreadId(sessionKey?: string | null): string | null {
if (!sessionKey) {
return null;
}
const match = sessionKey.match(/discord:(?:channel|group):\d+:thread:(\d+)/);
return match ? match[1] : null;
}
function extractDiscordSessionKind(sessionKey?: string | null): "channel" | "group" | "dm" | null {
if (!sessionKey) {
return null;
@@ -53,6 +65,17 @@ function normalizeDiscordOriginChannelId(value?: string | null): string | null {
return /^\d+$/.test(trimmed) ? trimmed : null;
}
function normalizeDiscordThreadId(value?: string | number | null): string | undefined {
if (typeof value === "number") {
return Number.isFinite(value) ? String(value) : undefined;
}
if (typeof value !== "string") {
return undefined;
}
const normalized = value.trim();
return /^\d+$/.test(normalized) ? normalized : undefined;
}
export function shouldHandleDiscordApprovalRequest(params: {
cfg: OpenClawConfig;
accountId?: string | null;
@@ -103,34 +126,63 @@ function createDiscordOriginTargetResolver(configOverride?: DiscordExecApprovalC
configOverride,
}),
resolveTurnSourceTarget: (request) => {
const sessionConversation = resolveApprovalRequestSessionConversation({
request,
channel: "discord",
});
const sessionKind = extractDiscordSessionKind(request.request.sessionKey?.trim() || null);
const turnSourceChannel = normalizeLowercaseStringOrEmpty(request.request.turnSourceChannel);
const rawTurnSourceTo = request.request.turnSourceTo?.trim() || "";
const turnSourceTo = normalizeDiscordOriginChannelId(rawTurnSourceTo);
const threadId =
normalizeDiscordThreadId(request.request.turnSourceThreadId) ??
normalizeDiscordThreadId(sessionConversation?.threadId) ??
undefined;
const hasExplicitOriginTarget = /^(?:channel|group):/i.test(rawTurnSourceTo);
if (turnSourceChannel !== "discord" || !turnSourceTo || sessionKind === "dm") {
return null;
}
return hasExplicitOriginTarget || sessionKind === "channel" || sessionKind === "group"
? { to: turnSourceTo }
? { to: turnSourceTo, threadId }
: null;
},
resolveSessionTarget: (sessionTarget, request) => {
const sessionConversation = resolveApprovalRequestSessionConversation({
request,
channel: "discord",
});
const sessionKind = extractDiscordSessionKind(request.request.sessionKey?.trim() || null);
if (sessionKind === "dm") {
return null;
}
const targetTo = normalizeDiscordOriginChannelId(sessionTarget.to);
return targetTo ? { to: targetTo } : null;
return targetTo
? {
to: targetTo,
threadId:
normalizeDiscordThreadId(sessionTarget.threadId) ??
normalizeDiscordThreadId(sessionConversation?.threadId) ??
undefined,
}
: null;
},
targetsMatch: (a, b) => a.to === b.to,
targetsMatch: (a, b) => a.to === b.to && a.threadId === b.threadId,
resolveFallbackTarget: (request) => {
const sessionConversation = resolveApprovalRequestSessionConversation({
request,
channel: "discord",
});
const sessionKind = extractDiscordSessionKind(request.request.sessionKey?.trim() || null);
if (sessionKind === "dm") {
return null;
}
const legacyChannelId = extractDiscordChannelId(request.request.sessionKey?.trim() || null);
return legacyChannelId ? { to: legacyChannelId } : null;
const fallbackChannelId = normalizeDiscordOriginChannelId(sessionConversation?.id);
return fallbackChannelId
? {
to: fallbackChannelId,
threadId: normalizeDiscordThreadId(sessionConversation?.threadId) ?? undefined,
}
: null;
},
});
}
@@ -175,6 +227,20 @@ export function createDiscordApprovalCapability(configOverride?: DiscordExecAppr
resolveOriginTarget: createDiscordOriginTargetResolver(configOverride),
resolveApproverDmTargets: createDiscordApproverDmTargetResolver(configOverride),
notifyOriginWhenDmOnly: true,
nativeRuntime: createLazyChannelApprovalNativeRuntimeAdapter({
eventKinds: ["exec", "plugin"],
isConfigured: ({ cfg, accountId }) =>
isDiscordExecApprovalClientEnabled({ cfg, accountId, configOverride }),
shouldHandle: ({ cfg, accountId, request }) =>
shouldHandleDiscordApprovalRequest({
cfg,
accountId,
request,
configOverride,
}),
load: async () =>
(await import("./approval-handler.runtime.js")).discordApprovalNativeRuntime,
}),
});
}

View File

@@ -797,6 +797,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
accountId: account.accountId,
config: ctx.cfg,
runtime: ctx.runtime,
channelRuntime: ctx.channelRuntime,
abortSignal: ctx.abortSignal,
mediaMaxMb: account.config.mediaMaxMb,
historyLimit: account.config.historyLimit,

View File

@@ -1,87 +1,24 @@
import {
Button,
Row,
Separator,
TextDisplay,
serializePayload,
type ButtonInteraction,
type ComponentData,
type MessagePayloadObject,
type TopLevelComponents,
} from "@buape/carbon";
import { ButtonStyle, Routes } from "discord-api-types/v10";
import { Button, type ButtonInteraction, type ComponentData } from "@buape/carbon";
import { ButtonStyle } from "discord-api-types/v10";
import { resolveApprovalOverGateway } from "openclaw/plugin-sdk/approval-handler-runtime";
import type { DiscordExecApprovalConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type {
ExecApprovalActionDescriptor,
ExecApprovalDecision,
ExecApprovalRequest,
ExecApprovalResolved,
PluginApprovalRequest,
PluginApprovalResolved,
} from "openclaw/plugin-sdk/infra-runtime";
import {
buildExecApprovalActionDescriptors,
createChannelNativeApprovalRuntime,
getExecApprovalApproverDmNoticeText,
resolveExecApprovalCommandDisplay,
type ExecApprovalChannelRuntime,
} from "openclaw/plugin-sdk/infra-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { logDebug, logError } from "openclaw/plugin-sdk/text-runtime";
import {
createDiscordApprovalCapability,
shouldHandleDiscordApprovalRequest,
} from "../approval-native.js";
import {
getDiscordExecApprovalApprovers,
isDiscordExecApprovalClientEnabled,
} from "../exec-approvals.js";
import { createDiscordClient, stripUndefinedFields } from "../send.shared.js";
import { DiscordUiContainer } from "../ui.js";
export { buildExecApprovalCustomId } from "../approval-handler.runtime.js";
import { getDiscordExecApprovalApprovers } from "../exec-approvals.js";
const EXEC_APPROVAL_KEY = "execapproval";
export { extractDiscordChannelId } from "../approval-native.js";
export type {
ExecApprovalRequest,
ExecApprovalResolved,
PluginApprovalRequest,
PluginApprovalResolved,
};
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type ApprovalResolved = ExecApprovalResolved | PluginApprovalResolved;
type ApprovalKind = "exec" | "plugin";
function buildDiscordApprovalDmRedirectNotice(): { content: string } {
return {
content: getExecApprovalApproverDmNoticeText(),
};
}
type PendingApproval = {
discordMessageId: string;
discordChannelId: string;
timeoutId?: NodeJS.Timeout;
};
type DiscordPendingDelivery = {
body: ReturnType<typeof stripUndefinedFields>;
};
type PreparedDeliveryTarget = {
discordChannelId: string;
recipientUserId?: string;
};
function resolveApprovalKindFromId(approvalId: string): ApprovalKind {
return approvalId.startsWith("plugin:") ? "plugin" : "exec";
}
function isPluginApprovalRequest(request: ApprovalRequest): request is PluginApprovalRequest {
return resolveApprovalKindFromId(request.id) === "plugin";
}
function encodeCustomIdValue(value: string): string {
return encodeURIComponent(value);
}
} from "openclaw/plugin-sdk/infra-runtime";
function decodeCustomIdValue(value: string): string {
try {
@@ -91,15 +28,6 @@ function decodeCustomIdValue(value: string): string {
}
}
export function buildExecApprovalCustomId(
approvalId: string,
action: ExecApprovalDecision,
): string {
return [`${EXEC_APPROVAL_KEY}:id=${encodeCustomIdValue(approvalId)}`, `action=${action}`].join(
";",
);
}
export function parseExecApprovalData(
data: ComponentData,
): { approvalId: string; action: ExecApprovalDecision } | null {
@@ -123,681 +51,18 @@ export function parseExecApprovalData(
};
}
type ExecApprovalContainerParams = {
cfg: OpenClawConfig;
accountId: string;
title: string;
description?: string;
commandPreview: string;
commandSecondaryPreview?: string | null;
metadataLines?: string[];
actionRow?: Row<Button>;
footer?: string;
accentColor?: string;
};
class ExecApprovalContainer extends DiscordUiContainer {
constructor(params: ExecApprovalContainerParams) {
const components: Array<TextDisplay | Separator | Row<Button>> = [
new TextDisplay(`## ${params.title}`),
];
if (params.description) {
components.push(new TextDisplay(params.description));
}
components.push(new Separator({ divider: true, spacing: "small" }));
components.push(new TextDisplay(`### Command\n\`\`\`\n${params.commandPreview}\n\`\`\``));
if (params.commandSecondaryPreview) {
components.push(
new TextDisplay(`### Shell Preview\n\`\`\`\n${params.commandSecondaryPreview}\n\`\`\``),
);
}
if (params.metadataLines?.length) {
components.push(new TextDisplay(params.metadataLines.join("\n")));
}
if (params.actionRow) {
components.push(params.actionRow);
}
if (params.footer) {
components.push(new Separator({ divider: false, spacing: "small" }));
components.push(new TextDisplay(`-# ${params.footer}`));
}
super({
cfg: params.cfg,
accountId: params.accountId,
components,
accentColor: params.accentColor,
});
}
}
class ExecApprovalActionButton extends Button {
customId: string;
label: string;
style: ButtonStyle;
constructor(params: { approvalId: string; descriptor: ExecApprovalActionDescriptor }) {
super();
this.customId = buildExecApprovalCustomId(params.approvalId, params.descriptor.decision);
this.label = params.descriptor.label;
this.style =
params.descriptor.style === "success"
? ButtonStyle.Success
: params.descriptor.style === "primary"
? ButtonStyle.Primary
: params.descriptor.style === "danger"
? ButtonStyle.Danger
: ButtonStyle.Secondary;
}
}
class ExecApprovalActionRow extends Row<Button> {
constructor(params: {
approvalId: string;
ask?: string | null;
allowedDecisions?: readonly ExecApprovalDecision[];
}) {
super(
buildExecApprovalActionDescriptors({
approvalCommandId: params.approvalId,
ask: params.ask,
allowedDecisions: params.allowedDecisions,
}).map(
(descriptor) => new ExecApprovalActionButton({ approvalId: params.approvalId, descriptor }),
),
);
}
}
function createApprovalActionRow(request: ApprovalRequest): Row<Button> {
if (isPluginApprovalRequest(request)) {
return new ExecApprovalActionRow({
approvalId: request.id,
});
}
return new ExecApprovalActionRow({
approvalId: request.id,
ask: request.request.ask,
allowedDecisions: request.request.allowedDecisions,
});
}
function buildExecApprovalMetadataLines(request: ExecApprovalRequest): string[] {
const lines: string[] = [];
if (request.request.cwd) {
lines.push(`- Working Directory: ${request.request.cwd}`);
}
if (request.request.host) {
lines.push(`- Host: ${request.request.host}`);
}
if (Array.isArray(request.request.envKeys) && request.request.envKeys.length > 0) {
lines.push(`- Env Overrides: ${request.request.envKeys.join(", ")}`);
}
if (request.request.agentId) {
lines.push(`- Agent: ${request.request.agentId}`);
}
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 };
}
function formatCommandPreview(commandText: string, maxChars: number): string {
const commandRaw =
commandText.length > maxChars ? `${commandText.slice(0, maxChars)}...` : commandText;
return commandRaw.replace(/`/g, "\u200b`");
}
function formatOptionalCommandPreview(
commandText: string | null | undefined,
maxChars: number,
): string | null {
if (!commandText) {
return null;
}
return formatCommandPreview(commandText, maxChars);
}
function resolveExecApprovalPreviews(
request: ExecApprovalRequest["request"],
maxChars: number,
secondaryMaxChars: number,
): { commandPreview: string; commandSecondaryPreview: string | null } {
const { commandText, commandPreview: secondaryPreview } =
resolveExecApprovalCommandDisplay(request);
return {
commandPreview: formatCommandPreview(commandText, maxChars),
commandSecondaryPreview: formatOptionalCommandPreview(secondaryPreview, secondaryMaxChars),
};
}
function createExecApprovalRequestContainer(params: {
request: ExecApprovalRequest;
cfg: OpenClawConfig;
accountId: string;
actionRow?: Row<Button>;
}): ExecApprovalContainer {
const { commandPreview, commandSecondaryPreview } = resolveExecApprovalPreviews(
params.request.request,
1000,
500,
);
const expiresAtSeconds = Math.max(0, Math.floor(params.request.expiresAtMs / 1000));
return new ExecApprovalContainer({
cfg: params.cfg,
accountId: params.accountId,
title: "Exec Approval Required",
description: "A command needs your approval.",
commandPreview,
commandSecondaryPreview,
metadataLines: buildExecApprovalMetadataLines(params.request),
actionRow: params.actionRow,
footer: `Expires <t:${expiresAtSeconds}:R> · ID: ${params.request.id}`,
accentColor: "#FFA500",
});
}
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;
cfg: OpenClawConfig;
accountId: string;
}): ExecApprovalContainer {
const { commandPreview, commandSecondaryPreview } = resolveExecApprovalPreviews(
params.request.request,
500,
300,
);
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: `Exec Approval: ${decisionLabel}`,
description: params.resolvedBy ? `Resolved by ${params.resolvedBy}` : "Resolved",
commandPreview,
commandSecondaryPreview,
footer: `ID: ${params.request.id}`,
accentColor,
});
}
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;
}): ExecApprovalContainer {
const { commandPreview, commandSecondaryPreview } = resolveExecApprovalPreviews(
params.request.request,
500,
300,
);
return new ExecApprovalContainer({
cfg: params.cfg,
accountId: params.accountId,
title: "Exec Approval: Expired",
description: "This approval request has expired.",
commandPreview,
commandSecondaryPreview,
footer: `ID: ${params.request.id}`,
accentColor: "#99AAB5",
});
}
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;
config: DiscordExecApprovalConfig;
gatewayUrl?: string;
cfg: OpenClawConfig;
runtime?: RuntimeEnv;
onResolve?: (id: string, decision: ExecApprovalDecision) => Promise<void>;
};
export class DiscordExecApprovalHandler {
private readonly runtime: ExecApprovalChannelRuntime<ApprovalRequest, ApprovalResolved>;
private opts: DiscordExecApprovalHandlerOpts;
constructor(opts: DiscordExecApprovalHandlerOpts) {
this.opts = opts;
this.runtime = createChannelNativeApprovalRuntime<
PendingApproval,
PreparedDeliveryTarget,
DiscordPendingDelivery
>({
label: "discord/exec-approvals",
clientDisplayName: "Discord Exec Approvals",
cfg: this.opts.cfg,
accountId: this.opts.accountId,
gatewayUrl: this.opts.gatewayUrl,
eventKinds: ["exec", "plugin"],
nativeAdapter: createDiscordApprovalCapability(this.opts.config).native,
isConfigured: () =>
isDiscordExecApprovalClientEnabled({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
configOverride: this.opts.config,
}),
shouldHandle: (request) => this.shouldHandle(request),
buildPendingContent: ({ request }) => {
const actionRow = createApprovalActionRow(request);
const container = isPluginApprovalRequest(request)
? createPluginApprovalRequestContainer({
request,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
actionRow,
})
: createExecApprovalRequestContainer({
request,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
actionRow,
});
const payload = buildExecApprovalPayload(container);
return {
body: stripUndefinedFields(serializePayload(payload)),
};
},
sendOriginNotice: async ({ originTarget }) => {
const { rest, request: discordRequest } = createDiscordClient(
{ token: this.opts.token, accountId: this.opts.accountId },
this.opts.cfg,
);
await discordRequest(
() =>
rest.post(Routes.channelMessages(originTarget.to), {
body: buildDiscordApprovalDmRedirectNotice(),
}) as Promise<{ id: string; channel_id: string }>,
"send-approval-dm-redirect-notice",
);
},
prepareTarget: async ({ plannedTarget }) => {
const { rest, request: discordRequest } = createDiscordClient(
{ token: this.opts.token, accountId: this.opts.accountId },
this.opts.cfg,
);
if (plannedTarget.surface === "origin") {
return {
dedupeKey: plannedTarget.target.to,
target: {
discordChannelId: plannedTarget.target.to,
},
};
}
const userId = plannedTarget.target.to;
const dmChannel = (await discordRequest(
() =>
rest.post(Routes.userChannels(), {
body: { recipient_id: userId },
}) as Promise<{ id: string }>,
"dm-channel",
)) as { id: string };
if (!dmChannel?.id) {
logError(`discord exec approvals: failed to create DM for user ${userId}`);
return null;
}
return {
dedupeKey: dmChannel.id,
target: {
discordChannelId: dmChannel.id,
recipientUserId: userId,
},
};
},
deliverTarget: async ({
plannedTarget,
preparedTarget,
pendingContent,
request: _request,
}) => {
const { rest, request: discordRequest } = createDiscordClient(
{ token: this.opts.token, accountId: this.opts.accountId },
this.opts.cfg,
);
const message = (await discordRequest(
() =>
rest.post(Routes.channelMessages(preparedTarget.discordChannelId), {
body: pendingContent.body,
}) as Promise<{ id: string; channel_id: string }>,
plannedTarget.surface === "origin" ? "send-approval-channel" : "send-approval",
)) as { id: string; channel_id: string };
if (!message?.id) {
if (plannedTarget.surface === "origin") {
logError("discord exec approvals: failed to send to channel");
} else if (preparedTarget.recipientUserId) {
logError(
`discord exec approvals: failed to send message to user ${preparedTarget.recipientUserId}`,
);
}
return null;
}
return {
discordMessageId: message.id,
discordChannelId: preparedTarget.discordChannelId,
};
},
onOriginNoticeError: ({ error }) => {
logError(`discord exec approvals: failed to send DM redirect notice: ${String(error)}`);
},
onDuplicateSkipped: ({ preparedTarget, request }) => {
logDebug(
`discord exec approvals: skipping duplicate approval ${request.id} for channel ${preparedTarget.dedupeKey}`,
);
},
onDelivered: ({ plannedTarget, preparedTarget, request }) => {
if (plannedTarget.surface === "origin") {
logDebug(
`discord exec approvals: sent approval ${request.id} to channel ${preparedTarget.target.discordChannelId}`,
);
return;
}
logDebug(
`discord exec approvals: sent approval ${request.id} to user ${plannedTarget.target.to}`,
);
},
onDeliveryError: ({ error, plannedTarget }) => {
if (plannedTarget.surface === "origin") {
logError(`discord exec approvals: failed to send to channel: ${String(error)}`);
return;
}
logError(
`discord exec approvals: failed to notify user ${plannedTarget.target.to}: ${String(error)}`,
);
},
finalizeResolved: async ({ request, resolved, entries }) => {
await this.finalizeResolved(request, resolved, entries);
},
finalizeExpired: async ({ request, entries }) => {
await this.finalizeExpired(request, entries);
},
});
}
shouldHandle(request: ApprovalRequest): boolean {
return shouldHandleDiscordApprovalRequest({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
request,
configOverride: this.opts.config,
});
}
async start(): Promise<void> {
await this.runtime.start();
}
async stop(): Promise<void> {
await this.runtime.stop();
}
async handleApprovalRequested(request: ApprovalRequest): Promise<void> {
await this.runtime.handleRequested(request);
}
async handleApprovalResolved(resolved: ApprovalResolved): Promise<void> {
await this.runtime.handleResolved(resolved);
}
async handleApprovalTimeout(approvalId: string, _source?: "channel" | "dm"): Promise<void> {
await this.runtime.handleExpired(approvalId);
}
private async finalizeResolved(
request: ApprovalRequest,
resolved: ApprovalResolved,
entries: PendingApproval[],
): Promise<void> {
const container = isPluginApprovalRequest(request)
? createPluginResolvedContainer({
request,
decision: resolved.decision,
resolvedBy: resolved.resolvedBy,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
})
: createExecResolvedContainer({
request,
decision: resolved.decision,
resolvedBy: resolved.resolvedBy,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
});
for (const pending of entries) {
await this.finalizeMessage(pending.discordChannelId, pending.discordMessageId, container);
}
}
private async finalizeExpired(
request: ApprovalRequest,
entries: PendingApproval[],
): Promise<void> {
const container = isPluginApprovalRequest(request)
? createPluginExpiredContainer({
request,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
})
: createExecExpiredContainer({
request,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
});
for (const pending of entries) {
await this.finalizeMessage(pending.discordChannelId, pending.discordMessageId, container);
}
}
private async finalizeMessage(
channelId: string,
messageId: string,
container: DiscordUiContainer,
): Promise<void> {
if (!this.opts.config.cleanupAfterResolve) {
await this.updateMessage(channelId, messageId, container);
return;
}
try {
const { rest, request: discordRequest } = createDiscordClient(
{ token: this.opts.token, accountId: this.opts.accountId },
this.opts.cfg,
);
await discordRequest(
() => rest.delete(Routes.channelMessage(channelId, messageId)) as Promise<void>,
"delete-approval",
);
} catch (err) {
logError(`discord exec approvals: failed to delete message: ${String(err)}`);
await this.updateMessage(channelId, messageId, container);
}
}
private async updateMessage(
channelId: string,
messageId: string,
container: DiscordUiContainer,
): Promise<void> {
try {
const { rest, request: discordRequest } = createDiscordClient(
{ token: this.opts.token, accountId: this.opts.accountId },
this.opts.cfg,
);
const payload = buildExecApprovalPayload(container);
await discordRequest(
() =>
rest.patch(Routes.channelMessage(channelId, messageId), {
body: stripUndefinedFields(serializePayload(payload)),
}),
"update-approval",
);
} catch (err) {
logError(`discord exec approvals: failed to update message: ${String(err)}`);
}
}
async resolveApproval(approvalId: string, decision: ExecApprovalDecision): Promise<boolean> {
const method =
resolveApprovalKindFromId(approvalId) === "plugin"
? "plugin.approval.resolve"
: "exec.approval.resolve";
logDebug(`discord exec approvals: resolving ${approvalId} with ${decision} via ${method}`);
try {
await this.runtime.request(method, {
id: approvalId,
decision,
});
logDebug(`discord exec approvals: resolved ${approvalId} successfully`);
return true;
} catch (err) {
logError(`discord exec approvals: resolve failed: ${String(err)}`);
return false;
}
}
/** Return the list of configured approver IDs. */
getApprovers(): string[] {
return getDiscordExecApprovalApprovers({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
configOverride: this.opts.config,
});
}
}
export type ExecApprovalButtonContext = {
handler: DiscordExecApprovalHandler;
getApprovers: () => string[];
resolveApproval: (approvalId: string, decision: ExecApprovalDecision) => Promise<boolean>;
};
export class ExecApprovalButton extends Button {
label = "execapproval";
customId = `${EXEC_APPROVAL_KEY}:seed=1`;
customId = "execapproval:seed=1";
style = ButtonStyle.Primary;
private ctx: ExecApprovalButtonContext;
constructor(ctx: ExecApprovalButtonContext) {
constructor(private readonly ctx: ExecApprovalButtonContext) {
super();
this.ctx = ctx;
}
async run(interaction: ButtonInteraction, data: ComponentData): Promise<void> {
@@ -808,14 +73,11 @@ export class ExecApprovalButton extends Button {
content: "This approval is no longer valid.",
ephemeral: true,
});
} catch {
// Interaction may have expired
}
} catch {}
return;
}
// Verify the user is an authorized approver
const approvers = this.ctx.handler.getApprovers();
const approvers = this.ctx.getApprovers();
const userId = interaction.userId;
if (!approvers.some((id) => String(id) === userId)) {
try {
@@ -823,9 +85,7 @@ export class ExecApprovalButton extends Button {
content: "⛔ You are not authorized to approve exec requests.",
ephemeral: true,
});
} catch {
// Interaction may have expired
}
} catch {}
return;
}
@@ -836,31 +96,52 @@ export class ExecApprovalButton extends Button {
? "Allowed (always)"
: "Denied";
// Acknowledge immediately so Discord does not fail the interaction while
// the gateway resolve roundtrip completes. The resolved event will update
// the approval card in-place with the final state.
try {
await interaction.acknowledge();
} catch {
// Interaction may have expired, try to continue anyway
}
const ok = await this.ctx.handler.resolveApproval(parsed.approvalId, parsed.action);
} catch {}
const ok = await this.ctx.resolveApproval(parsed.approvalId, parsed.action);
if (!ok) {
try {
await interaction.followUp({
content: `Failed to submit approval decision for **${decisionLabel}**. The request may have expired or already been resolved.`,
ephemeral: true,
});
} catch {
// Interaction may have expired
}
} catch {}
}
// On success, the handleApprovalResolved event will update the message with the final result
}
}
export function createExecApprovalButton(ctx: ExecApprovalButtonContext): Button {
return new ExecApprovalButton(ctx);
}
export function createDiscordExecApprovalButtonContext(params: {
cfg: OpenClawConfig;
accountId: string;
config: DiscordExecApprovalConfig;
gatewayUrl?: string;
}): ExecApprovalButtonContext {
return {
getApprovers: () =>
getDiscordExecApprovalApprovers({
cfg: params.cfg,
accountId: params.accountId,
configOverride: params.config,
}),
resolveApproval: async (approvalId, decision) => {
try {
await resolveApprovalOverGateway({
cfg: params.cfg,
approvalId,
decision,
gatewayUrl: params.gatewayUrl,
clientDisplayName: `Discord approval (${params.accountId})`,
});
return true;
} catch {
return false;
}
},
};
}

View File

@@ -102,8 +102,6 @@ describe("runDiscordGatewayLifecycle", () => {
function createLifecycleHarness(params?: {
gateway?: MockGateway;
start?: () => Promise<void>;
stop?: () => Promise<void>;
isDisallowedIntentsError?: (err: unknown) => boolean;
pendingGatewayEvents?: DiscordGatewayEvent[];
}) {
@@ -114,8 +112,6 @@ describe("runDiscordGatewayLifecycle", () => {
defaultGateway.isConnected = true;
return defaultGateway;
})();
const start = vi.fn(params?.start ?? (async () => undefined));
const stop = vi.fn(params?.stop ?? (async () => undefined));
const threadStop = vi.fn();
const runtimeLog = vi.fn();
const runtimeError = vi.fn();
@@ -143,8 +139,6 @@ describe("runDiscordGatewayLifecycle", () => {
exit: vi.fn(),
};
return {
start,
stop,
threadStop,
runtimeLog,
runtimeError,
@@ -157,7 +151,6 @@ describe("runDiscordGatewayLifecycle", () => {
isDisallowedIntentsError: params?.isDisallowedIntentsError ?? (() => false),
voiceManager: null,
voiceManagerRef: { current: null },
execApprovalsHandler: { start, stop },
threadBindings: { stop: threadStop },
gatewaySupervisor,
statusSink,
@@ -167,14 +160,10 @@ describe("runDiscordGatewayLifecycle", () => {
}
function expectLifecycleCleanup(params: {
start: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
threadStop: ReturnType<typeof vi.fn>;
waitCalls: number;
gatewaySupervisor: { detachLifecycle: ReturnType<typeof vi.fn> };
}) {
expect(params.start).toHaveBeenCalledTimes(1);
expect(params.stop).toHaveBeenCalledTimes(1);
expect(waitForDiscordGatewayStopMock).toHaveBeenCalledTimes(params.waitCalls);
expect(unregisterGatewayMock).toHaveBeenCalledWith("default");
expect(stopGatewayLoggingMock).toHaveBeenCalledTimes(1);
@@ -182,36 +171,28 @@ describe("runDiscordGatewayLifecycle", () => {
expect(params.gatewaySupervisor.detachLifecycle).toHaveBeenCalledTimes(1);
}
it("cleans up thread bindings when exec approvals startup fails", async () => {
const { lifecycleParams, start, stop, threadStop, gatewaySupervisor } = createLifecycleHarness({
start: async () => {
throw new Error("startup failed");
},
});
it("cleans up thread bindings when gateway wait fails before READY", async () => {
waitForDiscordGatewayStopMock.mockRejectedValueOnce(new Error("startup failed"));
const { lifecycleParams, threadStop, gatewaySupervisor } = createLifecycleHarness();
await expect(runDiscordGatewayLifecycle(lifecycleParams)).rejects.toThrow("startup failed");
expectLifecycleCleanup({
start,
stop,
threadStop,
waitCalls: 0,
waitCalls: 1,
gatewaySupervisor,
});
});
it("cleans up when gateway wait fails after startup", async () => {
waitForDiscordGatewayStopMock.mockRejectedValueOnce(new Error("gateway wait failed"));
const { lifecycleParams, start, stop, threadStop, gatewaySupervisor } =
createLifecycleHarness();
const { lifecycleParams, threadStop, gatewaySupervisor } = createLifecycleHarness();
await expect(runDiscordGatewayLifecycle(lifecycleParams)).rejects.toThrow(
"gateway wait failed",
);
expectLifecycleCleanup({
start,
stop,
threadStop,
waitCalls: 1,
gatewaySupervisor,
@@ -309,8 +290,9 @@ describe("runDiscordGatewayLifecycle", () => {
try {
const { emitter, gateway } = createGatewayHarness();
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
const { lifecycleParams, start, stop, threadStop, gatewaySupervisor } =
createLifecycleHarness({ gateway });
const { lifecycleParams, threadStop, gatewaySupervisor } = createLifecycleHarness({
gateway,
});
const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams);
lifecyclePromise.catch(() => {});
@@ -323,8 +305,6 @@ describe("runDiscordGatewayLifecycle", () => {
expect(gateway.connect).toHaveBeenCalledTimes(1);
expect(gateway.connect).toHaveBeenCalledWith(false);
expectLifecycleCleanup({
start,
stop,
threadStop,
waitCalls: 0,
gatewaySupervisor,
@@ -335,13 +315,14 @@ describe("runDiscordGatewayLifecycle", () => {
});
it("handles queued disallowed intents errors without waiting for gateway events", async () => {
const { lifecycleParams, start, stop, threadStop, runtimeError, gatewaySupervisor } =
createLifecycleHarness({
const { lifecycleParams, threadStop, runtimeError, gatewaySupervisor } = createLifecycleHarness(
{
pendingGatewayEvents: [
createGatewayEvent("disallowed-intents", "Fatal Gateway error: 4014"),
],
isDisallowedIntentsError: (err) => String(err).includes("4014"),
});
},
);
await expect(runDiscordGatewayLifecycle(lifecycleParams)).resolves.toBeUndefined();
@@ -349,8 +330,6 @@ describe("runDiscordGatewayLifecycle", () => {
expect.stringContaining("discord: gateway closed with code 4014"),
);
expectLifecycleCleanup({
start,
stop,
threadStop,
waitCalls: 0,
gatewaySupervisor,
@@ -358,10 +337,11 @@ describe("runDiscordGatewayLifecycle", () => {
});
it("logs queued non-fatal startup gateway errors and continues", async () => {
const { lifecycleParams, start, stop, threadStop, runtimeError, gatewaySupervisor } =
createLifecycleHarness({
const { lifecycleParams, threadStop, runtimeError, gatewaySupervisor } = createLifecycleHarness(
{
pendingGatewayEvents: [createGatewayEvent("other", "transient startup error")],
});
},
);
await expect(runDiscordGatewayLifecycle(lifecycleParams)).resolves.toBeUndefined();
@@ -369,8 +349,6 @@ describe("runDiscordGatewayLifecycle", () => {
expect.stringContaining("discord gateway error: Error: transient startup error"),
);
expectLifecycleCleanup({
start,
stop,
threadStop,
waitCalls: 1,
gatewaySupervisor,
@@ -378,7 +356,7 @@ describe("runDiscordGatewayLifecycle", () => {
});
it("throws queued fatal startup gateway errors", async () => {
const { lifecycleParams, start, stop, threadStop, gatewaySupervisor } = createLifecycleHarness({
const { lifecycleParams, threadStop, gatewaySupervisor } = createLifecycleHarness({
pendingGatewayEvents: [createGatewayEvent("fatal", "Fatal Gateway error: 4000")],
});
@@ -387,8 +365,6 @@ describe("runDiscordGatewayLifecycle", () => {
);
expectLifecycleCleanup({
start,
stop,
threadStop,
waitCalls: 0,
gatewaySupervisor,
@@ -396,7 +372,7 @@ describe("runDiscordGatewayLifecycle", () => {
});
it("throws queued reconnect exhaustion errors", async () => {
const { lifecycleParams, start, stop, threadStop, gatewaySupervisor } = createLifecycleHarness({
const { lifecycleParams, threadStop, gatewaySupervisor } = createLifecycleHarness({
pendingGatewayEvents: [
createGatewayEvent(
"reconnect-exhausted",
@@ -410,8 +386,6 @@ describe("runDiscordGatewayLifecycle", () => {
);
expectLifecycleCleanup({
start,
stop,
threadStop,
waitCalls: 0,
gatewaySupervisor,
@@ -424,7 +398,7 @@ describe("runDiscordGatewayLifecycle", () => {
const pendingGatewayEvents: DiscordGatewayEvent[] = [];
const { emitter, gateway } = createGatewayHarness();
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
const { lifecycleParams, start, stop, threadStop, runtimeError, gatewaySupervisor } =
const { lifecycleParams, threadStop, runtimeError, gatewaySupervisor } =
createLifecycleHarness({
gateway,
pendingGatewayEvents,
@@ -447,8 +421,6 @@ describe("runDiscordGatewayLifecycle", () => {
expect(gateway.disconnect).not.toHaveBeenCalled();
expect(gateway.connect).not.toHaveBeenCalled();
expectLifecycleCleanup({
start,
stop,
threadStop,
waitCalls: 0,
gatewaySupervisor,

View File

@@ -19,11 +19,6 @@ const DISCORD_GATEWAY_READY_POLL_MS = 250;
const DISCORD_GATEWAY_STARTUP_DISCONNECT_DRAIN_TIMEOUT_MS = 5_000;
const DISCORD_GATEWAY_STARTUP_TERMINATE_CLOSE_TIMEOUT_MS = 1_000;
type ExecApprovalsHandler = {
start: () => Promise<void>;
stop: () => Promise<void>;
};
type GatewayReadyWaitResult = "ready" | "stopped" | "timeout";
async function restartGatewayAfterReadyTimeout(params: {
@@ -362,7 +357,6 @@ export async function runDiscordGatewayLifecycle(params: {
isDisallowedIntentsError: (err: unknown) => boolean;
voiceManager: DiscordVoiceManager | null;
voiceManagerRef: { current: DiscordVoiceManager | null };
execApprovalsHandler: ExecApprovalsHandler | null;
threadBindings: { stop: () => void };
gatewaySupervisor: DiscordGatewaySupervisor;
statusSink?: DiscordMonitorStatusSink;
@@ -426,10 +420,6 @@ export async function runDiscordGatewayLifecycle(params: {
throw new DiscordGatewayLifecycleError(event);
});
try {
if (params.execApprovalsHandler) {
await params.execApprovalsHandler.start();
}
// Drain gateway errors emitted before lifecycle listeners were attached.
if (drainPendingGatewayErrors() === "stop") {
return;
@@ -474,9 +464,6 @@ export async function runDiscordGatewayLifecycle(params: {
await params.voiceManager.destroy();
params.voiceManagerRef.current = null;
}
if (params.execApprovalsHandler) {
await params.execApprovalsHandler.stop();
}
params.threadBindings.stop();
}
}

View File

@@ -3,6 +3,7 @@ import { RateLimitError } from "@buape/carbon";
import { AcpRuntimeError } from "openclaw/plugin-sdk/acp-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createRuntimeChannel } from "../../../../src/plugins/runtime/runtime-channel.js";
import {
baseConfig,
baseRuntime,
@@ -16,6 +17,7 @@ const {
clientFetchUserMock,
clientGetPluginMock,
clientHandleDeployRequestMock,
createDiscordExecApprovalButtonContextMock,
createDiscordMessageHandlerMock,
createDiscordNativeCommandMock,
createdBindingManagers,
@@ -317,6 +319,64 @@ describe("monitorDiscordProvider", () => {
expect(voiceRuntimeModuleLoadedMock).toHaveBeenCalledTimes(1);
});
it("wires exec approval button context from the resolved Discord account config", async () => {
const cfg = createConfigWithDiscordAccount();
const execApprovalsConfig = { enabled: true, approvers: ["123"] };
resolveDiscordAccountMock.mockReturnValue({
accountId: "default",
token: "cfg-token",
config: {
commands: { native: true, nativeSkills: false },
voice: { enabled: false },
agentComponents: { enabled: false },
execApprovals: execApprovalsConfig,
},
});
await monitorDiscordProvider({
config: cfg,
runtime: baseRuntime(),
});
expect(createDiscordExecApprovalButtonContextMock).toHaveBeenCalledWith({
cfg,
accountId: "default",
config: execApprovalsConfig,
});
});
it("registers the native approval runtime context when exec approvals are enabled", async () => {
const channelRuntime = createRuntimeChannel();
const execApprovalsConfig = { enabled: true, approvers: ["123"] };
resolveDiscordAccountMock.mockReturnValue({
accountId: "default",
token: "cfg-token",
config: {
commands: { native: true, nativeSkills: false },
voice: { enabled: false },
agentComponents: { enabled: false },
execApprovals: execApprovalsConfig,
},
});
await monitorDiscordProvider({
config: baseConfig(),
runtime: baseRuntime(),
channelRuntime,
});
expect(
channelRuntime.runtimeContexts.get({
channelId: "discord",
accountId: "default",
capability: "approval.native",
}),
).toEqual({
token: "cfg-token",
config: execApprovalsConfig,
});
});
it("treats ACP error status as uncertain during startup thread-binding probes", async () => {
getAcpSessionStatusMock.mockResolvedValue({ state: "error" });

View File

@@ -8,6 +8,8 @@ import {
} from "@buape/carbon";
import { GatewayCloseCodes, type GatewayPlugin } from "@buape/carbon/gateway";
import { Routes } from "discord-api-types/v10";
import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY } from "openclaw/plugin-sdk/approval-handler-runtime";
import { registerChannelRuntimeContext } from "openclaw/plugin-sdk/channel-runtime-context";
import {
listNativeCommandSpecsForConfig,
listSkillCommandsForAgents,
@@ -62,7 +64,10 @@ import {
} from "./agent-components.js";
import { createDiscordAutoPresenceController } from "./auto-presence.js";
import { resolveDiscordSlashCommandConfig } from "./commands.js";
import { createExecApprovalButton, DiscordExecApprovalHandler } from "./exec-approvals.js";
import {
createExecApprovalButton,
createDiscordExecApprovalButtonContext,
} from "./exec-approvals.js";
import type { MutableDiscordGateway } from "./gateway-handle.js";
import { createDiscordGatewayPlugin } from "./gateway-plugin.js";
import { createDiscordGatewaySupervisor } from "./gateway-supervisor.js";
@@ -90,6 +95,7 @@ export type MonitorDiscordOpts = {
accountId?: string;
config?: OpenClawConfig;
runtime?: RuntimeEnv;
channelRuntime?: import("openclaw/plugin-sdk/channel-core").PluginRuntime["channel"];
abortSignal?: AbortSignal;
mediaMaxMb?: number;
historyLimit?: number;
@@ -833,19 +839,24 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
// Initialize exec approvals handler if enabled
const execApprovalsConfig = discordCfg.execApprovals ?? {};
const execApprovalsHandler = isDiscordExecApprovalClientEnabled({
const execApprovalsEnabled = isDiscordExecApprovalClientEnabled({
cfg,
accountId: account.accountId,
configOverride: execApprovalsConfig,
})
? new DiscordExecApprovalHandler({
});
if (execApprovalsEnabled) {
registerChannelRuntimeContext({
channelRuntime: opts.channelRuntime,
channelId: "discord",
accountId: account.accountId,
capability: CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY,
context: {
token,
accountId: account.accountId,
config: execApprovalsConfig,
cfg,
runtime,
})
: null;
},
abortSignal: opts.abortSignal,
});
}
const agentComponentsConfig = discordCfg.agentComponents ?? {};
const agentComponentsEnabled = agentComponentsConfig.enabled ?? true;
@@ -875,8 +886,16 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
];
const modals: Modal[] = [];
if (execApprovalsHandler) {
components.push(createExecApprovalButton({ handler: execApprovalsHandler }));
if (execApprovalsEnabled) {
components.push(
createExecApprovalButton(
createDiscordExecApprovalButtonContext({
cfg,
accountId: account.accountId,
config: execApprovalsConfig,
}),
),
);
}
if (agentComponentsEnabled) {
@@ -1101,7 +1120,6 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
isDisallowedIntentsError: isDiscordDisallowedIntentsError,
voiceManager,
voiceManagerRef,
execApprovalsHandler,
threadBindings,
gatewaySupervisor,
});

View File

@@ -21,6 +21,15 @@ type ProviderMonitorTestMocks = {
clientGetPluginMock: Mock<(name: string) => unknown>;
clientConstructorOptionsMock: Mock<(options?: unknown) => void>;
createDiscordAutoPresenceControllerMock: Mock<() => unknown>;
createDiscordExecApprovalButtonContextMock: Mock<
(params?: {
cfg?: OpenClawConfig;
accountId?: string;
config?: unknown;
gatewayUrl?: string;
}) => { getApprovers: () => string[]; resolveApproval: () => Promise<boolean> }
>;
createExecApprovalButtonMock: Mock<(ctx?: unknown) => unknown>;
createDiscordNativeCommandMock: Mock<(params?: { command?: { name?: string } }) => unknown>;
createDiscordMessageHandlerMock: Mock<() => unknown>;
createNoopThreadBindingManagerMock: Mock<() => { stop: ReturnType<typeof vi.fn> }>;
@@ -82,6 +91,11 @@ const providerMonitorTestMocks: ProviderMonitorTestMocks = vi.hoisted(() => {
refresh: vi.fn(),
runNow: vi.fn(),
})),
createDiscordExecApprovalButtonContextMock: vi.fn(() => ({
getApprovers: () => [],
resolveApproval: async () => false,
})),
createExecApprovalButtonMock: vi.fn(() => ({ id: "exec-approval" })),
createDiscordNativeCommandMock: vi.fn((params?: { command?: { name?: string } }) => ({
name: params?.command?.name ?? "mock-command",
})),
@@ -154,6 +168,8 @@ const {
clientGetPluginMock,
clientConstructorOptionsMock,
createDiscordAutoPresenceControllerMock,
createDiscordExecApprovalButtonContextMock,
createExecApprovalButtonMock,
createDiscordNativeCommandMock,
createDiscordMessageHandlerMock,
createNoopThreadBindingManagerMock,
@@ -209,6 +225,11 @@ export function resetDiscordProviderMonitorMocks(params?: {
refresh: vi.fn(),
runNow: vi.fn(),
}));
createDiscordExecApprovalButtonContextMock.mockClear().mockImplementation(() => ({
getApprovers: () => [],
resolveApproval: async () => false,
}));
createExecApprovalButtonMock.mockClear().mockImplementation(() => ({ id: "exec-approval" }));
createDiscordNativeCommandMock.mockClear().mockImplementation((input) => ({
name: input?.command?.name ?? "mock-command",
}));
@@ -442,15 +463,8 @@ vi.mock(buildDiscordSourceModuleId("monitor/commands.js"), () => ({
}));
vi.mock(buildDiscordSourceModuleId("monitor/exec-approvals.js"), () => ({
createExecApprovalButton: () => ({ id: "exec-approval" }),
DiscordExecApprovalHandler: class DiscordExecApprovalHandler {
async start() {
return undefined;
}
async stop() {
return undefined;
}
},
createExecApprovalButton: createExecApprovalButtonMock,
createDiscordExecApprovalButtonContext: createDiscordExecApprovalButtonContextMock,
}));
vi.mock(buildDiscordSourceModuleId("monitor/gateway-plugin.js"), () => ({

View File

@@ -635,7 +635,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
},
}),
},
auth: feishuApprovalAuth,
approvalCapability: feishuApprovalAuth,
secrets: {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,

View File

@@ -127,7 +127,7 @@ export const googlechatPlugin = createChatChannelPlugin({
},
}),
},
auth: googleChatApprovalAuth,
approvalCapability: googleChatApprovalAuth,
secrets: {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,

View File

@@ -2,7 +2,7 @@ import {
createResolvedApproverActionAuthAdapter,
resolveApprovalApprovers,
} from "openclaw/plugin-sdk/approval-auth-runtime";
import { normalizeMatrixApproverId } from "./exec-approvals.js";
import { normalizeMatrixApproverId } from "./approval-ids.js";
import { resolveMatrixAccount } from "./matrix/accounts.js";
import type { CoreConfig } from "./types.js";

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { matrixApprovalNativeRuntime } from "./approval-handler.runtime.js";
describe("matrixApprovalNativeRuntime", () => {
it("uses a longer code fence when resolved commands contain triple backticks", async () => {
const result = await matrixApprovalNativeRuntime.presentation.buildResolvedResult({
cfg: {} as never,
accountId: "default",
context: {
client: {} as never,
},
request: {
id: "req-1",
request: {
command: "echo hi",
},
createdAtMs: 0,
expiresAtMs: 1_000,
},
resolved: {
id: "req-1",
decision: "allow-once",
ts: 0,
},
view: {
approvalKind: "exec",
approvalId: "req-1",
decision: "allow-once",
commandText: "echo ```danger```",
} as never,
entry: {} as never,
});
expect(result).toEqual({
kind: "update",
payload: [
"Exec approval: Allowed once",
"",
"Command",
"````",
"echo ```danger```",
"````",
].join("\n"),
});
});
});

View File

@@ -0,0 +1,393 @@
import type {
ChannelApprovalCapabilityHandlerContext,
PendingApprovalView,
ResolvedApprovalView,
} from "openclaw/plugin-sdk/approval-handler-runtime";
import { createChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approval-native-runtime";
import {
buildExecApprovalPendingReplyPayload,
buildPluginApprovalPendingReplyPayload,
type ExecApprovalReplyDecision,
} from "openclaw/plugin-sdk/approval-reply-runtime";
import { buildPluginApprovalResolvedReplyPayload } from "openclaw/plugin-sdk/approval-runtime";
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
import {
buildMatrixApprovalReactionHint,
listMatrixApprovalReactionBindings,
registerMatrixApprovalReactionTarget,
unregisterMatrixApprovalReactionTarget,
} from "./approval-reactions.js";
import {
isMatrixAnyApprovalClientEnabled,
shouldHandleMatrixApprovalRequest,
} from "./exec-approvals.js";
import { resolveMatrixAccount } from "./matrix/accounts.js";
import { deleteMatrixMessage, editMatrixMessage } from "./matrix/actions/messages.js";
import { repairMatrixDirectRooms } from "./matrix/direct-management.js";
import type { MatrixClient } from "./matrix/sdk.js";
import { reactMatrixMessage, sendMessageMatrix } from "./matrix/send.js";
import { resolveMatrixTargetIdentity } from "./matrix/target-ids.js";
import type { CoreConfig } from "./types.js";
type PendingMessage = {
roomId: string;
messageIds: readonly string[];
reactionEventId: string;
};
type PreparedMatrixTarget = {
to: string;
roomId: string;
threadId?: string;
};
type PendingApprovalContent = {
approvalId: string;
text: string;
allowedDecisions: readonly ExecApprovalReplyDecision[];
};
type ReactionTargetRef = {
roomId: string;
eventId: string;
};
export type MatrixApprovalHandlerDeps = {
nowMs?: () => number;
sendMessage?: typeof sendMessageMatrix;
reactMessage?: typeof reactMatrixMessage;
editMessage?: typeof editMatrixMessage;
deleteMessage?: typeof deleteMatrixMessage;
repairDirectRooms?: typeof repairMatrixDirectRooms;
};
export type MatrixApprovalHandlerContext = {
client: MatrixClient;
deps?: MatrixApprovalHandlerDeps;
};
function resolveHandlerContext(params: ChannelApprovalCapabilityHandlerContext): {
accountId: string;
context: MatrixApprovalHandlerContext;
} | null {
const context = params.context as MatrixApprovalHandlerContext | undefined;
const accountId = params.accountId?.trim() || "";
if (!context?.client || !accountId) {
return null;
}
return { accountId, context };
}
function normalizePendingMessageIds(entry: PendingMessage): string[] {
return Array.from(new Set(entry.messageIds.map((messageId) => messageId.trim()).filter(Boolean)));
}
function normalizeReactionTargetRef(params: ReactionTargetRef): ReactionTargetRef | null {
const roomId = params.roomId.trim();
const eventId = params.eventId.trim();
if (!roomId || !eventId) {
return null;
}
return { roomId, eventId };
}
function normalizeThreadId(value?: string | number | null): string | undefined {
const trimmed = value == null ? "" : String(value).trim();
return trimmed || undefined;
}
async function prepareTarget(
params: ChannelApprovalCapabilityHandlerContext & {
rawTarget: {
to: string;
threadId?: string | number | null;
};
},
): Promise<PreparedMatrixTarget | null> {
const resolved = resolveHandlerContext(params);
if (!resolved) {
return null;
}
const target = resolveMatrixTargetIdentity(params.rawTarget.to);
if (!target) {
return null;
}
const threadId = normalizeThreadId(params.rawTarget.threadId);
if (target.kind === "user") {
const account = resolveMatrixAccount({
cfg: params.cfg as CoreConfig,
accountId: resolved.accountId,
});
const repairDirectRooms = resolved.context.deps?.repairDirectRooms ?? repairMatrixDirectRooms;
const repaired = await repairDirectRooms({
client: resolved.context.client,
remoteUserId: target.id,
encrypted: account.config.encryption === true,
});
if (!repaired.activeRoomId) {
return null;
}
return {
to: `room:${repaired.activeRoomId}`,
roomId: repaired.activeRoomId,
threadId,
};
}
return {
to: `room:${target.id}`,
roomId: target.id,
threadId,
};
}
function buildPendingApprovalContent(params: {
view: PendingApprovalView;
nowMs: number;
}): PendingApprovalContent {
const allowedDecisions = params.view.actions.map((action) => action.decision);
const payload =
params.view.approvalKind === "plugin"
? buildPluginApprovalPendingReplyPayload({
request: {
id: params.view.approvalId,
request: {
title: params.view.title,
description: params.view.description ?? "",
severity: params.view.severity,
toolName: params.view.toolName ?? undefined,
pluginId: params.view.pluginId ?? undefined,
agentId: params.view.agentId ?? undefined,
},
createdAtMs: 0,
expiresAtMs: params.view.expiresAtMs,
} satisfies PluginApprovalRequest,
nowMs: params.nowMs,
allowedDecisions,
})
: buildExecApprovalPendingReplyPayload({
approvalId: params.view.approvalId,
approvalSlug: params.view.approvalId.slice(0, 8),
approvalCommandId: params.view.approvalId,
ask: params.view.ask ?? undefined,
agentId: params.view.agentId ?? undefined,
allowedDecisions,
command: params.view.commandText,
cwd: params.view.cwd ?? undefined,
host: params.view.host === "node" ? "node" : "gateway",
nodeId: params.view.nodeId ?? undefined,
sessionKey: params.view.sessionKey ?? undefined,
expiresAtMs: params.view.expiresAtMs,
nowMs: params.nowMs,
});
const hint = buildMatrixApprovalReactionHint(allowedDecisions);
const text = payload.text ?? "";
return {
approvalId: params.view.approvalId,
text: hint ? (text ? `${hint}\n\n${text}` : hint) : text,
allowedDecisions,
};
}
function buildResolvedApprovalText(view: ResolvedApprovalView): string {
if (view.approvalKind === "plugin") {
return (
buildPluginApprovalResolvedReplyPayload({
resolved: {
id: view.approvalId,
decision: view.decision,
resolvedBy: view.resolvedBy ?? undefined,
ts: 0,
},
}).text ?? ""
);
}
const decisionLabel =
view.decision === "allow-once"
? "Allowed once"
: view.decision === "allow-always"
? "Allowed always"
: "Denied";
return [
`Exec approval: ${decisionLabel}`,
"",
"Command",
buildMarkdownCodeBlock(view.commandText),
].join("\n");
}
function buildMarkdownCodeBlock(text: string): string {
const longestFence = Math.max(...Array.from(text.matchAll(/`+/g), (match) => match[0].length), 0);
const fence = "`".repeat(Math.max(3, longestFence + 1));
return [fence, text, fence].join("\n");
}
export const matrixApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter<
PendingApprovalContent,
PreparedMatrixTarget,
PendingMessage,
ReactionTargetRef
>({
eventKinds: ["exec", "plugin"],
availability: {
isConfigured: ({ cfg, accountId, context }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return false;
}
return isMatrixAnyApprovalClientEnabled({
cfg,
accountId: resolved.accountId,
});
},
shouldHandle: ({ cfg, accountId, request, context }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return false;
}
return shouldHandleMatrixApprovalRequest({
cfg,
accountId: resolved.accountId,
request: request as ExecApprovalRequest | PluginApprovalRequest,
});
},
},
presentation: {
buildPendingPayload: ({ view, nowMs }) =>
buildPendingApprovalContent({
view,
nowMs,
}),
buildResolvedResult: ({ view }) => ({
kind: "update",
payload: buildResolvedApprovalText(view),
}),
buildExpiredResult: () => ({ kind: "delete" }),
},
transport: {
prepareTarget: ({ cfg, accountId, context, plannedTarget }) => {
return prepareTarget({
cfg,
accountId,
context,
rawTarget: plannedTarget.target,
}).then((preparedTarget) =>
preparedTarget
? {
dedupeKey: buildChannelApprovalNativeTargetKey({
to: preparedTarget.roomId,
threadId: preparedTarget.threadId,
}),
target: preparedTarget,
}
: null,
);
},
deliverPending: async ({ cfg, accountId, context, preparedTarget, pendingPayload }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return null;
}
const sendMessage = resolved.context.deps?.sendMessage ?? sendMessageMatrix;
const reactMessage = resolved.context.deps?.reactMessage ?? reactMatrixMessage;
const result = await sendMessage(preparedTarget.to, pendingPayload.text, {
cfg: cfg as CoreConfig,
accountId: resolved.accountId,
client: resolved.context.client,
threadId: preparedTarget.threadId,
});
const messageIds = Array.from(
new Set(
(result.messageIds ?? [result.messageId])
.map((messageId) => messageId.trim())
.filter(Boolean),
),
);
const reactionEventId =
result.primaryMessageId?.trim() || messageIds[0] || result.messageId.trim();
await Promise.allSettled(
listMatrixApprovalReactionBindings(pendingPayload.allowedDecisions).map(
async ({ emoji }) => {
await reactMessage(result.roomId, reactionEventId, emoji, {
cfg: cfg as CoreConfig,
accountId: resolved.accountId,
client: resolved.context.client,
});
},
),
);
return {
roomId: result.roomId,
messageIds,
reactionEventId,
};
},
updateEntry: async ({ cfg, accountId, context, entry, payload }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return;
}
const editMessage = resolved.context.deps?.editMessage ?? editMatrixMessage;
const deleteMessage = resolved.context.deps?.deleteMessage ?? deleteMatrixMessage;
const [primaryMessageId, ...staleMessageIds] = normalizePendingMessageIds(entry);
if (!primaryMessageId) {
return;
}
const text = payload as string;
await Promise.allSettled([
editMessage(entry.roomId, primaryMessageId, text, {
cfg: cfg as CoreConfig,
accountId: resolved.accountId,
client: resolved.context.client,
}),
...staleMessageIds.map(async (messageId) => {
await deleteMessage(entry.roomId, messageId, {
cfg: cfg as CoreConfig,
accountId: resolved.accountId,
client: resolved.context.client,
reason: "approval resolved",
});
}),
]);
},
deleteEntry: async ({ cfg, accountId, context, entry, phase }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return;
}
const deleteMessage = resolved.context.deps?.deleteMessage ?? deleteMatrixMessage;
await Promise.allSettled(
normalizePendingMessageIds(entry).map(async (messageId) => {
await deleteMessage(entry.roomId, messageId, {
cfg: cfg as CoreConfig,
accountId: resolved.accountId,
client: resolved.context.client,
reason: phase === "expired" ? "approval expired" : "approval resolved",
});
}),
);
},
},
interactions: {
bindPending: ({ entry, pendingPayload }) => {
const target = normalizeReactionTargetRef({
roomId: entry.roomId,
eventId: entry.reactionEventId,
});
if (!target) {
return null;
}
registerMatrixApprovalReactionTarget({
roomId: target.roomId,
eventId: target.eventId,
approvalId: pendingPayload.approvalId,
allowedDecisions: pendingPayload.allowedDecisions,
});
return target;
},
unbindPending: ({ binding }) => {
const target = normalizeReactionTargetRef(binding);
if (!target) {
return;
}
unregisterMatrixApprovalReactionTarget(target);
},
},
});

View File

@@ -0,0 +1,6 @@
import { normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
export function normalizeMatrixApproverId(value: string | number): string | undefined {
const normalized = normalizeMatrixUserId(String(value));
return normalized || undefined;
}

View File

@@ -69,7 +69,7 @@ describe("matrix native approval adapter", () => {
preferredSurface: "both",
supportsOriginSurface: true,
supportsApproverDmSurface: true,
notifyOriginWhenDmOnly: false,
notifyOriginWhenDmOnly: true,
});
});
@@ -117,7 +117,33 @@ describe("matrix native approval adapter", () => {
expect(targets).toEqual([{ to: "user:@owner:example.org" }]);
});
it("keeps plugin forwarding fallback active when native delivery is exec-only", () => {
it("falls back to the session-key origin target for plugin approvals when the store is missing", async () => {
const target = await matrixNativeApprovalAdapter.native?.resolveOriginTarget?.({
cfg: buildConfig({
dm: { allowFrom: ["@owner:example.org"] },
}),
accountId: "default",
approvalKind: "plugin",
request: {
id: "plugin:req-1",
request: {
title: "Plugin Approval Required",
description: "Allow plugin access",
pluginId: "git-tools",
sessionKey: "agent:main:matrix:channel:!ops:example.org:thread:$root",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(target).toEqual({
to: "room:!ops:example.org",
threadId: "$root",
});
});
it("suppresses same-channel plugin forwarding when Matrix native delivery is available", () => {
const shouldSuppress = matrixNativeApprovalAdapter.delivery?.shouldSuppressForwardingFallback;
if (!shouldSuppress) {
throw new Error("delivery suppression helper unavailable");
@@ -125,7 +151,9 @@ describe("matrix native approval adapter", () => {
expect(
shouldSuppress({
cfg: buildConfig(),
cfg: buildConfig({
dm: { allowFrom: ["@owner:example.org"] },
}),
approvalKind: "plugin",
target: {
channel: "matrix",
@@ -133,9 +161,11 @@ describe("matrix native approval adapter", () => {
accountId: "default",
},
request: {
id: "req-1",
id: "plugin:req-1",
request: {
command: "echo hi",
title: "Plugin Approval Required",
description: "Allow plugin action",
pluginId: "git-tools",
turnSourceChannel: "matrix",
turnSourceTo: "room:!ops:example.org",
turnSourceAccountId: "default",
@@ -144,7 +174,7 @@ describe("matrix native approval adapter", () => {
expiresAtMs: 1000,
},
}),
).toBe(false);
).toBe(true);
});
it("preserves room-id case when matching Matrix origin targets", async () => {
@@ -241,7 +271,63 @@ describe("matrix native approval adapter", () => {
});
});
it("disables matrix-native plugin approval delivery", () => {
it("reports exec initiating-surface availability independently from plugin auth", () => {
const cfg = buildConfig({
dm: { allowFrom: ["@owner:example.org"] },
execApprovals: {
enabled: false,
approvers: [],
target: "both",
},
});
expect(
matrixApprovalCapability.getActionAvailabilityState?.({
cfg,
accountId: "default",
action: "approve",
approvalKind: "plugin",
}),
).toEqual({ kind: "enabled" });
expect(
matrixApprovalCapability.getExecInitiatingSurfaceState?.({
cfg,
accountId: "default",
action: "approve",
}),
).toEqual({ kind: "disabled" });
});
it("enables matrix-native plugin approval delivery when DM approvers are configured", () => {
const capabilities = matrixNativeApprovalAdapter.native?.describeDeliveryCapabilities({
cfg: buildConfig({
dm: { allowFrom: ["@owner:example.org"] },
}),
accountId: "default",
approvalKind: "plugin",
request: {
id: "plugin:req-1",
request: {
title: "Plugin Approval Required",
description: "Allow plugin access",
pluginId: "git-tools",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(capabilities).toEqual({
enabled: true,
preferredSurface: "both",
supportsOriginSurface: true,
supportsApproverDmSurface: true,
notifyOriginWhenDmOnly: true,
});
});
it("keeps matrix-native plugin approval delivery disabled without DM approvers", () => {
const capabilities = matrixNativeApprovalAdapter.native?.describeDeliveryCapabilities({
cfg: buildConfig(),
accountId: "default",
@@ -260,10 +346,10 @@ describe("matrix native approval adapter", () => {
expect(capabilities).toEqual({
enabled: false,
preferredSurface: "approver-dm",
supportsOriginSurface: false,
supportsApproverDmSurface: false,
notifyOriginWhenDmOnly: false,
preferredSurface: "both",
supportsOriginSurface: true,
supportsApproverDmSurface: true,
notifyOriginWhenDmOnly: true,
});
});
});

View File

@@ -3,23 +3,27 @@ import {
createApproverRestrictedNativeApprovalCapability,
splitChannelApprovalCapability,
} from "openclaw/plugin-sdk/approval-delivery-runtime";
import { createLazyChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
import {
createChannelApproverDmTargetResolver,
createChannelNativeOriginTargetResolver,
resolveApprovalRequestSessionConversation,
} from "openclaw/plugin-sdk/approval-native-runtime";
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
normalizeOptionalStringifiedId,
} from "openclaw/plugin-sdk/text-runtime";
import { getMatrixApprovalAuthApprovers, matrixApprovalAuth } from "./approval-auth.js";
import { normalizeMatrixApproverId } from "./approval-ids.js";
import {
getMatrixApprovalApprovers,
getMatrixExecApprovalApprovers,
isMatrixExecApprovalAuthorizedSender,
isMatrixAnyApprovalClientEnabled,
isMatrixApprovalClientEnabled,
isMatrixExecApprovalClientEnabled,
isMatrixExecApprovalAuthorizedSender,
resolveMatrixExecApprovalTarget,
shouldHandleMatrixExecApprovalRequest,
shouldHandleMatrixApprovalRequest,
} from "./exec-approvals.js";
import { listMatrixAccountIds } from "./matrix/accounts.js";
import { normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
@@ -27,14 +31,8 @@ import { resolveMatrixTargetIdentity } from "./matrix/target-ids.js";
import type { CoreConfig } from "./types.js";
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type ApprovalKind = "exec" | "plugin";
type MatrixOriginTarget = { to: string; threadId?: string };
const MATRIX_PLUGIN_NATIVE_DELIVERY_DISABLED = {
enabled: false,
preferredSurface: "approver-dm" as const,
supportsOriginSurface: false,
supportsApproverDmSurface: false,
notifyOriginWhenDmOnly: false,
};
function normalizeComparableTarget(value: string): string {
const target = resolveMatrixTargetIdentity(value);
@@ -93,10 +91,63 @@ function hasMatrixPluginApprovers(params: { cfg: CoreConfig; accountId?: string
return getMatrixApprovalAuthApprovers(params).length > 0;
}
function availabilityState(enabled: boolean) {
return enabled ? ({ kind: "enabled" } as const) : ({ kind: "disabled" } as const);
}
function hasMatrixApprovalApprovers(params: {
cfg: CoreConfig;
accountId?: string | null;
approvalKind: ApprovalKind;
}): boolean {
return (
getMatrixApprovalApprovers({
cfg: params.cfg,
accountId: params.accountId,
approvalKind: params.approvalKind,
}).length > 0
);
}
function hasAnyMatrixApprovalApprovers(params: {
cfg: CoreConfig;
accountId?: string | null;
}): boolean {
return (
getMatrixExecApprovalApprovers(params).length > 0 ||
getMatrixApprovalAuthApprovers(params).length > 0
);
}
function isMatrixPluginAuthorizedSender(params: {
cfg: CoreConfig;
accountId?: string | null;
senderId?: string | null;
}): boolean {
const normalizedSenderId = params.senderId
? normalizeMatrixApproverId(params.senderId)
: undefined;
if (!normalizedSenderId) {
return false;
}
return getMatrixApprovalAuthApprovers(params).includes(normalizedSenderId);
}
function resolveSuppressionAccountId(params: {
target: { accountId?: string | null };
request: { request: { turnSourceAccountId?: string | null } };
}): string | undefined {
return (
params.target.accountId?.trim() ||
params.request.request.turnSourceAccountId?.trim() ||
undefined
);
}
const resolveMatrixOriginTarget = createChannelNativeOriginTargetResolver({
channel: "matrix",
shouldHandleRequest: ({ cfg, accountId, request }) =>
shouldHandleMatrixExecApprovalRequest({
shouldHandleMatrixApprovalRequest({
cfg,
accountId,
request,
@@ -104,22 +155,42 @@ const resolveMatrixOriginTarget = createChannelNativeOriginTargetResolver({
resolveTurnSourceTarget: resolveTurnSourceMatrixOriginTarget,
resolveSessionTarget: resolveSessionMatrixOriginTarget,
targetsMatch: matrixTargetsMatch,
});
const resolveMatrixApproverDmTargets = createChannelApproverDmTargetResolver({
shouldHandleRequest: ({ cfg, accountId, request }) =>
shouldHandleMatrixExecApprovalRequest({
cfg,
accountId,
resolveFallbackTarget: (request) => {
const sessionConversation = resolveApprovalRequestSessionConversation({
request,
}),
resolveApprovers: getMatrixExecApprovalApprovers,
mapApprover: (approver) => {
const normalized = normalizeMatrixUserId(approver);
return normalized ? { to: `user:${normalized}` } : null;
channel: "matrix",
});
if (!sessionConversation) {
return null;
}
const target = resolveMatrixNativeTarget(sessionConversation.id);
if (!target) {
return null;
}
return {
to: target,
threadId: normalizeOptionalStringifiedId(sessionConversation.threadId),
};
},
});
function resolveMatrixApproverDmTargets(params: {
cfg: CoreConfig;
accountId?: string | null;
approvalKind: ApprovalKind;
request: ApprovalRequest;
}): { to: string }[] {
if (!shouldHandleMatrixApprovalRequest(params)) {
return [];
}
return getMatrixApprovalApprovers(params)
.map((approver) => {
const normalized = normalizeMatrixUserId(approver);
return normalized ? { to: `user:${normalized}` } : null;
})
.filter((target): target is { to: string } => target !== null);
}
const matrixNativeApprovalCapability = createApproverRestrictedNativeApprovalCapability({
channel: "matrix",
channelLabel: "Matrix",
@@ -132,19 +203,42 @@ const matrixNativeApprovalCapability = createApproverRestrictedNativeApprovalCap
},
listAccountIds: listMatrixAccountIds,
hasApprovers: ({ cfg, accountId }) =>
getMatrixExecApprovalApprovers({ cfg, accountId }).length > 0,
hasAnyMatrixApprovalApprovers({
cfg: cfg as CoreConfig,
accountId,
}),
isExecAuthorizedSender: ({ cfg, accountId, senderId }) =>
isMatrixExecApprovalAuthorizedSender({ cfg, accountId, senderId }),
isPluginAuthorizedSender: ({ cfg, accountId, senderId }) =>
isMatrixPluginAuthorizedSender({
cfg: cfg as CoreConfig,
accountId,
senderId,
}),
isNativeDeliveryEnabled: ({ cfg, accountId }) =>
isMatrixExecApprovalClientEnabled({ cfg, accountId }),
resolveNativeDeliveryMode: ({ cfg, accountId }) =>
resolveMatrixExecApprovalTarget({ cfg, accountId }),
requireMatchingTurnSourceChannel: true,
resolveSuppressionAccountId: ({ target, request }) =>
normalizeOptionalString(target.accountId) ??
normalizeOptionalString(request.request.turnSourceAccountId),
resolveSuppressionAccountId,
resolveOriginTarget: resolveMatrixOriginTarget,
resolveApproverDmTargets: resolveMatrixApproverDmTargets,
notifyOriginWhenDmOnly: true,
nativeRuntime: createLazyChannelApprovalNativeRuntimeAdapter({
eventKinds: ["exec", "plugin"],
isConfigured: ({ cfg, accountId }) =>
isMatrixAnyApprovalClientEnabled({
cfg,
accountId,
}),
shouldHandle: ({ cfg, accountId, request }) =>
shouldHandleMatrixApprovalRequest({
cfg,
accountId,
request,
}),
load: async () => (await import("./approval-handler.runtime.js")).matrixApprovalNativeRuntime,
}),
});
const splitMatrixApprovalCapability = splitChannelApprovalCapability(
@@ -157,32 +251,42 @@ type MatrixForwardingSuppressionParams = Parameters<
>[0];
const matrixDeliveryAdapter = matrixBaseDeliveryAdapter && {
...matrixBaseDeliveryAdapter,
shouldSuppressForwardingFallback: (params: MatrixForwardingSuppressionParams) =>
params.approvalKind === "plugin"
? false
: (matrixBaseDeliveryAdapter.shouldSuppressForwardingFallback?.(params) ?? false),
shouldSuppressForwardingFallback: (params: MatrixForwardingSuppressionParams) => {
const accountId = resolveSuppressionAccountId(params);
if (
!hasMatrixApprovalApprovers({
cfg: params.cfg as CoreConfig,
accountId,
approvalKind: params.approvalKind,
})
) {
return false;
}
return matrixBaseDeliveryAdapter.shouldSuppressForwardingFallback?.(params) ?? false;
},
};
const matrixExecOnlyNativeApprovalAdapter = matrixBaseNativeApprovalAdapter && {
const matrixNativeAdapter = matrixBaseNativeApprovalAdapter && {
describeDeliveryCapabilities: (
params: Parameters<typeof matrixBaseNativeApprovalAdapter.describeDeliveryCapabilities>[0],
) =>
params.approvalKind === "plugin"
? MATRIX_PLUGIN_NATIVE_DELIVERY_DISABLED
: matrixBaseNativeApprovalAdapter.describeDeliveryCapabilities(params),
resolveOriginTarget: async (
params: Parameters<NonNullable<typeof matrixBaseNativeApprovalAdapter.resolveOriginTarget>>[0],
) =>
params.approvalKind === "plugin"
? null
: ((await matrixBaseNativeApprovalAdapter.resolveOriginTarget?.(params)) ?? null),
resolveApproverDmTargets: async (
params: Parameters<
NonNullable<typeof matrixBaseNativeApprovalAdapter.resolveApproverDmTargets>
>[0],
) =>
params.approvalKind === "plugin"
? []
: ((await matrixBaseNativeApprovalAdapter.resolveApproverDmTargets?.(params)) ?? []),
) => {
const capabilities = matrixBaseNativeApprovalAdapter.describeDeliveryCapabilities(params);
const hasApprovers = hasMatrixApprovalApprovers({
cfg: params.cfg as CoreConfig,
accountId: params.accountId,
approvalKind: params.approvalKind,
});
const clientEnabled = isMatrixApprovalClientEnabled({
cfg: params.cfg,
accountId: params.accountId,
approvalKind: params.approvalKind,
});
return {
...capabilities,
enabled: capabilities.enabled && hasApprovers && clientEnabled,
};
},
resolveOriginTarget: matrixBaseNativeApprovalAdapter.resolveOriginTarget,
resolveApproverDmTargets: matrixBaseNativeApprovalAdapter.resolveApproverDmTargets,
};
export const matrixApprovalCapability = createChannelApprovalCapability({
@@ -203,28 +307,39 @@ export const matrixApprovalCapability = createChannelApprovalCapability({
}
return matrixApprovalAuth.authorizeActorAction(params);
},
getActionAvailabilityState: (params) =>
hasMatrixPluginApprovers({
cfg: params.cfg as CoreConfig,
accountId: params.accountId,
})
? ({ kind: "enabled" } as const)
: (matrixNativeApprovalCapability.getActionAvailabilityState?.(params) ??
({ kind: "disabled" } as const)),
describeExecApprovalSetup: matrixNativeApprovalCapability.describeExecApprovalSetup,
approvals: {
delivery: matrixDeliveryAdapter,
native: matrixExecOnlyNativeApprovalAdapter,
render: matrixNativeApprovalCapability.render,
getActionAvailabilityState: (params) => {
if (params.approvalKind === "plugin") {
return availabilityState(
hasMatrixPluginApprovers({
cfg: params.cfg as CoreConfig,
accountId: params.accountId,
}),
);
}
return (
matrixNativeApprovalCapability.getActionAvailabilityState?.(params) ?? {
kind: "disabled",
}
);
},
getExecInitiatingSurfaceState: (params) =>
matrixNativeApprovalCapability.getExecInitiatingSurfaceState?.(params) ??
({ kind: "disabled" } as const),
describeExecApprovalSetup: matrixNativeApprovalCapability.describeExecApprovalSetup,
delivery: matrixDeliveryAdapter,
nativeRuntime: matrixNativeApprovalCapability.nativeRuntime,
native: matrixNativeAdapter,
render: matrixNativeApprovalCapability.render,
});
export const matrixNativeApprovalAdapter = {
auth: {
authorizeActorAction: matrixApprovalCapability.authorizeActorAction,
getActionAvailabilityState: matrixApprovalCapability.getActionAvailabilityState,
getExecInitiatingSurfaceState: matrixApprovalCapability.getExecInitiatingSurfaceState,
},
delivery: matrixDeliveryAdapter,
nativeRuntime: matrixApprovalCapability.nativeRuntime,
render: matrixApprovalCapability.render,
native: matrixExecOnlyNativeApprovalAdapter,
native: matrixNativeAdapter,
};

View File

@@ -487,6 +487,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount, MatrixProbe> =
return monitorMatrixProvider({
runtime: ctx.runtime,
channelRuntime: ctx.channelRuntime,
abortSignal: ctx.abortSignal,
mediaMaxMb: account.config.mediaMaxMb,
initialSyncLimit: account.config.initialSyncLimit,

View File

@@ -1,37 +1,56 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const gatewayRuntimeHoisted = vi.hoisted(() => ({
requestSpy: vi.fn(),
withClientSpy: vi.fn(),
const approvalRuntimeHoisted = vi.hoisted(() => ({
resolveApprovalOverGatewaySpy: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/gateway-runtime", () => ({
withOperatorApprovalsGatewayClient: gatewayRuntimeHoisted.withClientSpy,
vi.mock("openclaw/plugin-sdk/approval-handler-runtime", () => ({
resolveApprovalOverGateway: (...args: unknown[]) =>
approvalRuntimeHoisted.resolveApprovalOverGatewaySpy(...args),
}));
describe("resolveMatrixExecApproval", () => {
describe("resolveMatrixApproval", () => {
beforeEach(() => {
gatewayRuntimeHoisted.requestSpy.mockReset();
gatewayRuntimeHoisted.withClientSpy.mockReset().mockImplementation(async (_params, run) => {
await run({
request: gatewayRuntimeHoisted.requestSpy,
} as never);
});
approvalRuntimeHoisted.resolveApprovalOverGatewaySpy.mockReset();
});
it("submits exec approval resolutions through the gateway approvals client", async () => {
const { resolveMatrixExecApproval } = await import("./exec-approval-resolver.js");
it("submits exec approval resolutions through the shared gateway resolver", async () => {
const { resolveMatrixApproval } = await import("./exec-approval-resolver.js");
await resolveMatrixExecApproval({
await resolveMatrixApproval({
cfg: {} as never,
approvalId: "req-123",
decision: "allow-once",
senderId: "@owner:example.org",
});
expect(gatewayRuntimeHoisted.requestSpy).toHaveBeenCalledWith("exec.approval.resolve", {
id: "req-123",
expect(approvalRuntimeHoisted.resolveApprovalOverGatewaySpy).toHaveBeenCalledWith({
cfg: {} as never,
approvalId: "req-123",
decision: "allow-once",
senderId: "@owner:example.org",
gatewayUrl: undefined,
clientDisplayName: "Matrix approval (@owner:example.org)",
});
});
it("passes plugin approval ids through unchanged", async () => {
const { resolveMatrixApproval } = await import("./exec-approval-resolver.js");
await resolveMatrixApproval({
cfg: {} as never,
approvalId: "plugin:req-123",
decision: "deny",
senderId: "@owner:example.org",
});
expect(approvalRuntimeHoisted.resolveApprovalOverGatewaySpy).toHaveBeenCalledWith({
cfg: {} as never,
approvalId: "plugin:req-123",
decision: "deny",
senderId: "@owner:example.org",
gatewayUrl: undefined,
clientDisplayName: "Matrix approval (@owner:example.org)",
});
});

View File

@@ -1,28 +1,25 @@
import { resolveApprovalOverGateway } from "openclaw/plugin-sdk/approval-handler-runtime";
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { isApprovalNotFoundError } from "openclaw/plugin-sdk/error-runtime";
import { withOperatorApprovalsGatewayClient } from "openclaw/plugin-sdk/gateway-runtime";
export { isApprovalNotFoundError };
export async function resolveMatrixExecApproval(params: {
export async function resolveMatrixApproval(params: {
cfg: OpenClawConfig;
approvalId: string;
decision: ExecApprovalReplyDecision;
senderId?: string | null;
gatewayUrl?: string;
}): Promise<void> {
await withOperatorApprovalsGatewayClient(
{
config: params.cfg,
gatewayUrl: params.gatewayUrl,
clientDisplayName: `Matrix approval (${params.senderId?.trim() || "unknown"})`,
},
async (gatewayClient) => {
await gatewayClient.request("exec.approval.resolve", {
id: params.approvalId,
decision: params.decision,
});
},
);
await resolveApprovalOverGateway({
cfg: params.cfg,
approvalId: params.approvalId,
decision: params.decision,
senderId: params.senderId,
gatewayUrl: params.gatewayUrl,
clientDisplayName: `Matrix approval (${params.senderId?.trim() || "unknown"})`,
});
}
export const resolveMatrixExecApproval = resolveMatrixApproval;

View File

@@ -1,556 +0,0 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
clearMatrixApprovalReactionTargetsForTest,
resolveMatrixApprovalReactionTarget,
} from "./approval-reactions.js";
import { MatrixExecApprovalHandler } from "./exec-approvals-handler.js";
const baseRequest = {
id: "9f1c7d5d-b1fb-46ef-ac45-662723b65bb7",
request: {
command: "npm view diver name version description",
agentId: "main",
sessionKey: "agent:main:matrix:channel:!ops:example.org",
turnSourceChannel: "matrix",
turnSourceTo: "room:!ops:example.org",
turnSourceThreadId: "$thread",
turnSourceAccountId: "default",
},
createdAtMs: 1000,
expiresAtMs: 61_000,
};
function createHandler(cfg: OpenClawConfig, accountId = "default") {
const client = {} as never;
const sendMessage = vi
.fn()
.mockResolvedValueOnce({ messageId: "$m1", roomId: "!ops:example.org" })
.mockResolvedValue({ messageId: "$m2", roomId: "!dm-owner:example.org" });
const reactMessage = vi.fn().mockResolvedValue(undefined);
const editMessage = vi.fn().mockResolvedValue({ eventId: "$edit1" });
const deleteMessage = vi.fn().mockResolvedValue(undefined);
const repairDirectRooms = vi.fn().mockResolvedValue({
activeRoomId: "!dm-owner:example.org",
});
const handler = new MatrixExecApprovalHandler(
{
client,
accountId,
cfg,
},
{
nowMs: () => 1000,
sendMessage,
reactMessage,
editMessage,
deleteMessage,
repairDirectRooms,
},
);
return {
client,
handler,
sendMessage,
reactMessage,
editMessage,
deleteMessage,
repairDirectRooms,
};
}
afterEach(() => {
vi.useRealTimers();
clearMatrixApprovalReactionTargetsForTest();
});
describe("MatrixExecApprovalHandler", () => {
it("sends approval prompts to the originating matrix room when target=channel", async () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
execApprovals: {
enabled: true,
approvers: ["@owner:example.org"],
target: "channel",
},
},
},
} as OpenClawConfig;
const { handler, sendMessage } = createHandler(cfg);
await handler.handleRequested(baseRequest);
expect(sendMessage).toHaveBeenCalledWith(
"room:!ops:example.org",
expect.stringContaining("/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once"),
expect.objectContaining({
accountId: "default",
threadId: "$thread",
}),
);
});
it("seeds emoji reactions for each allowed approval decision", async () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
execApprovals: {
enabled: true,
approvers: ["@owner:example.org"],
target: "channel",
},
},
},
} as OpenClawConfig;
const { handler, reactMessage, sendMessage } = createHandler(cfg);
await handler.handleRequested(baseRequest);
expect(sendMessage).toHaveBeenCalledWith(
"room:!ops:example.org",
expect.stringContaining("React here: ✅ Allow once, ♾️ Allow always, ❌ Deny"),
expect.anything(),
);
expect(reactMessage).toHaveBeenCalledTimes(3);
expect(reactMessage).toHaveBeenNthCalledWith(
1,
"!ops:example.org",
"$m1",
"✅",
expect.anything(),
);
expect(reactMessage).toHaveBeenNthCalledWith(
2,
"!ops:example.org",
"$m1",
"♾️",
expect.anything(),
);
expect(reactMessage).toHaveBeenNthCalledWith(
3,
"!ops:example.org",
"$m1",
"❌",
expect.anything(),
);
});
it("falls back to approver dms when channel routing is unavailable", async () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
execApprovals: {
enabled: true,
approvers: ["@owner:example.org"],
target: "channel",
},
},
},
} as OpenClawConfig;
const { client, handler, sendMessage, repairDirectRooms } = createHandler(cfg);
await handler.handleRequested({
...baseRequest,
request: {
...baseRequest.request,
turnSourceChannel: "slack",
turnSourceTo: "channel:C1",
turnSourceAccountId: null,
turnSourceThreadId: null,
},
});
expect(repairDirectRooms).toHaveBeenCalledWith({
client,
remoteUserId: "@owner:example.org",
encrypted: false,
});
expect(sendMessage).toHaveBeenCalledWith(
"room:!dm-owner:example.org",
expect.stringContaining("/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once"),
expect.objectContaining({
accountId: "default",
}),
);
});
it("does not send foreign-channel approvals from unbound multi-account matrix configs", async () => {
const cfg = {
channels: {
matrix: {
accounts: {
default: {
homeserver: "https://matrix.example.org",
userId: "@bot-default:example.org",
accessToken: "tok-default",
execApprovals: {
enabled: true,
approvers: ["@owner:example.org"],
target: "channel",
},
},
ops: {
homeserver: "https://matrix.example.org",
userId: "@bot-ops:example.org",
accessToken: "tok-ops",
execApprovals: {
enabled: true,
approvers: ["@owner:example.org"],
target: "channel",
},
},
},
},
},
} as OpenClawConfig;
const defaultHandler = createHandler(cfg, "default");
const opsHandler = createHandler(cfg, "ops");
const request = {
...baseRequest,
request: {
...baseRequest.request,
sessionKey: "agent:main:missing",
turnSourceChannel: "slack",
turnSourceTo: "channel:C1",
turnSourceAccountId: null,
turnSourceThreadId: null,
},
};
await defaultHandler.handler.handleRequested(request);
await opsHandler.handler.handleRequested(request);
expect(defaultHandler.sendMessage).not.toHaveBeenCalled();
expect(opsHandler.sendMessage).not.toHaveBeenCalled();
expect(defaultHandler.repairDirectRooms).not.toHaveBeenCalled();
expect(opsHandler.repairDirectRooms).not.toHaveBeenCalled();
});
it("does not double-send when the origin room is the approver dm", async () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
execApprovals: {
enabled: true,
approvers: ["@owner:example.org"],
target: "dm",
},
},
},
} as OpenClawConfig;
const { handler, sendMessage } = createHandler(cfg);
await handler.handleRequested({
...baseRequest,
request: {
...baseRequest.request,
sessionKey: "agent:main:matrix:direct:!dm-owner:example.org",
turnSourceTo: "room:!dm-owner:example.org",
turnSourceThreadId: undefined,
},
});
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(
"room:!dm-owner:example.org",
expect.stringContaining("/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once"),
expect.objectContaining({
accountId: "default",
}),
);
});
it("edits tracked approval messages when resolved", async () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
execApprovals: {
enabled: true,
approvers: ["@owner:example.org"],
target: "both",
},
},
},
} as OpenClawConfig;
const { handler, editMessage } = createHandler(cfg);
await handler.handleRequested(baseRequest);
await handler.handleResolved({
id: baseRequest.id,
decision: "allow-once",
resolvedBy: "matrix:@owner:example.org",
ts: 2000,
});
expect(editMessage).toHaveBeenCalledWith(
"!ops:example.org",
"$m1",
expect.stringContaining("Exec approval: Allowed once"),
expect.objectContaining({
accountId: "default",
}),
);
});
it("anchors reactions on the first chunk and clears stale chunks on resolve", async () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
execApprovals: {
enabled: true,
approvers: ["@owner:example.org"],
target: "channel",
},
},
},
} as OpenClawConfig;
const { handler, sendMessage, reactMessage, editMessage, deleteMessage } = createHandler(cfg);
sendMessage.mockReset().mockResolvedValue({
messageId: "$m3",
primaryMessageId: "$m1",
messageIds: ["$m1", "$m2", "$m3"],
roomId: "!ops:example.org",
});
await handler.handleRequested(baseRequest);
await handler.handleResolved({
id: baseRequest.id,
decision: "allow-once",
resolvedBy: "matrix:@owner:example.org",
ts: 2000,
});
expect(reactMessage).toHaveBeenNthCalledWith(
1,
"!ops:example.org",
"$m1",
"✅",
expect.anything(),
);
expect(editMessage).toHaveBeenCalledWith(
"!ops:example.org",
"$m1",
expect.stringContaining("Exec approval: Allowed once"),
expect.anything(),
);
expect(deleteMessage).toHaveBeenCalledWith(
"!ops:example.org",
"$m2",
expect.objectContaining({ reason: "approval resolved" }),
);
expect(deleteMessage).toHaveBeenCalledWith(
"!ops:example.org",
"$m3",
expect.objectContaining({ reason: "approval resolved" }),
);
});
it("deletes tracked approval messages when they expire", async () => {
vi.useFakeTimers();
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
execApprovals: {
enabled: true,
approvers: ["@owner:example.org"],
target: "both",
},
},
},
} as OpenClawConfig;
const { handler, deleteMessage } = createHandler(cfg);
await handler.handleRequested(baseRequest);
await vi.advanceTimersByTimeAsync(60_000);
expect(deleteMessage).toHaveBeenCalledWith(
"!ops:example.org",
"$m1",
expect.objectContaining({
accountId: "default",
reason: "approval expired",
}),
);
});
it("deletes every chunk of a tracked approval prompt when it expires", async () => {
vi.useFakeTimers();
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
execApprovals: {
enabled: true,
approvers: ["@owner:example.org"],
target: "channel",
},
},
},
} as OpenClawConfig;
const { handler, sendMessage, deleteMessage } = createHandler(cfg);
sendMessage.mockReset().mockResolvedValue({
messageId: "$m3",
primaryMessageId: "$m1",
messageIds: ["$m1", "$m2", "$m3"],
roomId: "!ops:example.org",
});
await handler.handleRequested(baseRequest);
await vi.advanceTimersByTimeAsync(60_000);
expect(deleteMessage).toHaveBeenCalledWith(
"!ops:example.org",
"$m1",
expect.objectContaining({ reason: "approval expired" }),
);
expect(deleteMessage).toHaveBeenCalledWith(
"!ops:example.org",
"$m2",
expect.objectContaining({ reason: "approval expired" }),
);
expect(deleteMessage).toHaveBeenCalledWith(
"!ops:example.org",
"$m3",
expect.objectContaining({ reason: "approval expired" }),
);
});
it("clears tracked approval anchors when the handler stops", async () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
execApprovals: {
enabled: true,
approvers: ["@owner:example.org"],
target: "channel",
},
},
},
} as OpenClawConfig;
const { handler } = createHandler(cfg);
await handler.handleRequested(baseRequest);
await handler.stop();
expect(
resolveMatrixApprovalReactionTarget({
roomId: "!ops:example.org",
eventId: "$m1",
reactionKey: "✅",
}),
).toBeNull();
});
it("honors request decision constraints in pending approval text", async () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
execApprovals: {
enabled: true,
approvers: ["@owner:example.org"],
target: "channel",
},
},
},
} as OpenClawConfig;
const { handler, sendMessage, reactMessage } = createHandler(cfg);
await handler.handleRequested({
...baseRequest,
request: {
...baseRequest.request,
ask: "always",
allowedDecisions: ["allow-once", "deny"],
},
});
expect(sendMessage).toHaveBeenCalledWith(
"room:!ops:example.org",
expect.not.stringContaining("allow-always"),
expect.anything(),
);
expect(sendMessage).toHaveBeenCalledWith(
"room:!ops:example.org",
expect.stringContaining("React here: ✅ Allow once, ❌ Deny"),
expect.anything(),
);
expect(reactMessage).toHaveBeenCalledTimes(2);
expect(reactMessage).toHaveBeenNthCalledWith(
1,
"!ops:example.org",
"$m1",
"✅",
expect.anything(),
);
expect(reactMessage).toHaveBeenNthCalledWith(
2,
"!ops:example.org",
"$m1",
"❌",
expect.anything(),
);
});
it("keeps the reaction hint at the start of long approval prompts", async () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
execApprovals: {
enabled: true,
approvers: ["@owner:example.org"],
target: "channel",
},
},
},
} as OpenClawConfig;
const { handler, sendMessage } = createHandler(cfg);
await handler.handleRequested({
...baseRequest,
request: {
...baseRequest.request,
command: `printf '%s' "${"x".repeat(8_000)}"`,
},
});
const sentText = sendMessage.mock.calls[0]?.[1];
expect(typeof sentText).toBe("string");
expect(sentText).toContain("Pending command:");
expect(sentText).toMatch(
/^React here: ✅ Allow once, ♾️ Allow always, ❌ Deny\n\nApproval required\./,
);
});
});

View File

@@ -1,408 +0,0 @@
import {
buildExecApprovalPendingReplyPayload,
type ExecApprovalReplyDecision,
getExecApprovalApproverDmNoticeText,
resolveExecApprovalAllowedDecisions,
resolveExecApprovalCommandDisplay,
} from "openclaw/plugin-sdk/approval-reply-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
createChannelNativeApprovalRuntime,
type ExecApprovalChannelRuntime,
type ExecApprovalRequest,
type ExecApprovalResolved,
} from "openclaw/plugin-sdk/infra-runtime";
import { normalizeOptionalStringifiedId } from "openclaw/plugin-sdk/text-runtime";
import { matrixNativeApprovalAdapter } from "./approval-native.js";
import {
buildMatrixApprovalReactionHint,
listMatrixApprovalReactionBindings,
registerMatrixApprovalReactionTarget,
unregisterMatrixApprovalReactionTarget,
} from "./approval-reactions.js";
import {
isMatrixExecApprovalClientEnabled,
shouldHandleMatrixExecApprovalRequest,
} from "./exec-approvals.js";
import { resolveMatrixAccount } from "./matrix/accounts.js";
import { deleteMatrixMessage, editMatrixMessage } from "./matrix/actions/messages.js";
import { repairMatrixDirectRooms } from "./matrix/direct-management.js";
import type { MatrixClient } from "./matrix/sdk.js";
import { reactMatrixMessage, sendMessageMatrix } from "./matrix/send.js";
import { resolveMatrixTargetIdentity } from "./matrix/target-ids.js";
import type { CoreConfig } from "./types.js";
type ApprovalRequest = ExecApprovalRequest;
type ApprovalResolved = ExecApprovalResolved;
type PendingMessage = {
roomId: string;
messageIds: readonly string[];
reactionEventId: string;
};
type PreparedMatrixTarget = {
to: string;
roomId: string;
threadId?: string;
};
type PendingApprovalContent = {
approvalId: string;
text: string;
allowedDecisions: readonly ExecApprovalReplyDecision[];
};
type ReactionTargetRef = {
roomId: string;
eventId: string;
};
export type MatrixExecApprovalHandlerOpts = {
client: MatrixClient;
accountId: string;
cfg: OpenClawConfig;
gatewayUrl?: string;
};
export type MatrixExecApprovalHandlerDeps = {
nowMs?: () => number;
sendMessage?: typeof sendMessageMatrix;
reactMessage?: typeof reactMatrixMessage;
editMessage?: typeof editMatrixMessage;
deleteMessage?: typeof deleteMatrixMessage;
repairDirectRooms?: typeof repairMatrixDirectRooms;
};
function normalizePendingMessageIds(entry: PendingMessage): string[] {
return Array.from(new Set(entry.messageIds.map((messageId) => messageId.trim()).filter(Boolean)));
}
function normalizeReactionTargetRef(params: ReactionTargetRef): ReactionTargetRef | null {
const roomId = params.roomId.trim();
const eventId = params.eventId.trim();
if (!roomId || !eventId) {
return null;
}
return { roomId, eventId };
}
function buildReactionTargetRefKey(params: ReactionTargetRef): string | null {
const normalized = normalizeReactionTargetRef(params);
if (!normalized) {
return null;
}
return `${normalized.roomId}\u0000${normalized.eventId}`;
}
function isHandlerConfigured(params: { cfg: OpenClawConfig; accountId: string }): boolean {
return isMatrixExecApprovalClientEnabled(params);
}
function buildPendingApprovalContent(params: {
request: ApprovalRequest;
nowMs: number;
}): PendingApprovalContent {
const allowedDecisions =
params.request.request.allowedDecisions ??
resolveExecApprovalAllowedDecisions({ ask: params.request.request.ask ?? undefined });
const payload = buildExecApprovalPendingReplyPayload({
approvalId: params.request.id,
approvalSlug: params.request.id.slice(0, 8),
approvalCommandId: params.request.id,
ask: params.request.request.ask ?? undefined,
agentId: params.request.request.agentId ?? undefined,
allowedDecisions,
command: resolveExecApprovalCommandDisplay(params.request.request).commandText,
cwd: params.request.request.cwd ?? undefined,
host: params.request.request.host === "node" ? "node" : "gateway",
nodeId: params.request.request.nodeId ?? undefined,
sessionKey: params.request.request.sessionKey ?? undefined,
expiresAtMs: params.request.expiresAtMs,
nowMs: params.nowMs,
});
const hint = buildMatrixApprovalReactionHint(allowedDecisions);
const text = payload.text ?? "";
return {
approvalId: params.request.id,
// Reactions are anchored to the first Matrix event for a chunked send, so keep
// the reaction hint at the start of the message where that anchor always lives.
text: hint ? (text ? `${hint}\n\n${text}` : hint) : text,
allowedDecisions,
};
}
function buildResolvedApprovalText(params: {
request: ApprovalRequest;
resolved: ApprovalResolved;
}): string {
const command = resolveExecApprovalCommandDisplay(params.request.request).commandText;
const decisionLabel =
params.resolved.decision === "allow-once"
? "Allowed once"
: params.resolved.decision === "allow-always"
? "Allowed always"
: "Denied";
return [`Exec approval: ${decisionLabel}`, "", "Command", "```", command, "```"].join("\n");
}
export class MatrixExecApprovalHandler {
private readonly runtime: ExecApprovalChannelRuntime;
private readonly trackedReactionTargets = new Map<string, ReactionTargetRef>();
private readonly nowMs: () => number;
private readonly sendMessage: typeof sendMessageMatrix;
private readonly reactMessage: typeof reactMatrixMessage;
private readonly editMessage: typeof editMatrixMessage;
private readonly deleteMessage: typeof deleteMatrixMessage;
private readonly repairDirectRooms: typeof repairMatrixDirectRooms;
constructor(
private readonly opts: MatrixExecApprovalHandlerOpts,
deps: MatrixExecApprovalHandlerDeps = {},
) {
this.nowMs = deps.nowMs ?? Date.now;
this.sendMessage = deps.sendMessage ?? sendMessageMatrix;
this.reactMessage = deps.reactMessage ?? reactMatrixMessage;
this.editMessage = deps.editMessage ?? editMatrixMessage;
this.deleteMessage = deps.deleteMessage ?? deleteMatrixMessage;
this.repairDirectRooms = deps.repairDirectRooms ?? repairMatrixDirectRooms;
this.runtime = createChannelNativeApprovalRuntime<
PendingMessage,
PreparedMatrixTarget,
PendingApprovalContent,
ApprovalRequest,
ApprovalResolved
>({
label: "matrix/exec-approvals",
clientDisplayName: `Matrix Exec Approvals (${this.opts.accountId})`,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
gatewayUrl: this.opts.gatewayUrl,
eventKinds: ["exec"],
nowMs: this.nowMs,
nativeAdapter: matrixNativeApprovalAdapter.native,
isConfigured: () =>
isHandlerConfigured({ cfg: this.opts.cfg, accountId: this.opts.accountId }),
shouldHandle: (request) =>
shouldHandleMatrixExecApprovalRequest({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
request,
}),
buildPendingContent: ({ request, nowMs }) =>
buildPendingApprovalContent({
request,
nowMs,
}),
sendOriginNotice: async ({ originTarget }) => {
const preparedTarget = await this.prepareTarget(originTarget);
if (!preparedTarget) {
return;
}
await this.sendMessage(preparedTarget.to, getExecApprovalApproverDmNoticeText(), {
cfg: this.opts.cfg as CoreConfig,
accountId: this.opts.accountId,
client: this.opts.client,
threadId: preparedTarget.threadId,
});
},
prepareTarget: async ({ plannedTarget }) => {
const preparedTarget = await this.prepareTarget(plannedTarget.target);
if (!preparedTarget) {
return null;
}
return {
dedupeKey: `${preparedTarget.roomId}:${preparedTarget.threadId ?? ""}`,
target: preparedTarget,
};
},
deliverTarget: async ({ preparedTarget, pendingContent }) => {
const result = await this.sendMessage(preparedTarget.to, pendingContent.text, {
cfg: this.opts.cfg as CoreConfig,
accountId: this.opts.accountId,
client: this.opts.client,
threadId: preparedTarget.threadId,
});
const messageIds = Array.from(
new Set(
(result.messageIds ?? [result.messageId])
.map((messageId) => messageId.trim())
.filter(Boolean),
),
);
const reactionEventId =
result.primaryMessageId?.trim() || messageIds[0] || result.messageId.trim();
this.trackReactionTarget({
roomId: result.roomId,
eventId: reactionEventId,
approvalId: pendingContent.approvalId,
allowedDecisions: pendingContent.allowedDecisions,
});
await Promise.allSettled(
listMatrixApprovalReactionBindings(pendingContent.allowedDecisions).map(
async ({ emoji }) => {
await this.reactMessage(result.roomId, reactionEventId, emoji, {
cfg: this.opts.cfg as CoreConfig,
accountId: this.opts.accountId,
client: this.opts.client,
});
},
),
);
return {
roomId: result.roomId,
messageIds,
reactionEventId,
};
},
finalizeResolved: async ({ request, resolved, entries }) => {
await this.finalizeResolved(request, resolved, entries);
},
finalizeExpired: async ({ entries }) => {
await this.clearPending(entries);
},
});
}
async start(): Promise<void> {
await this.runtime.start();
}
async stop(): Promise<void> {
await this.runtime.stop();
this.clearTrackedReactionTargets();
}
async handleRequested(request: ApprovalRequest): Promise<void> {
await this.runtime.handleRequested(request);
}
async handleResolved(resolved: ApprovalResolved): Promise<void> {
await this.runtime.handleResolved(resolved);
}
private async prepareTarget(rawTarget: {
to: string;
threadId?: string | number | null;
}): Promise<PreparedMatrixTarget | null> {
const target = resolveMatrixTargetIdentity(rawTarget.to);
if (!target) {
return null;
}
const threadId = normalizeOptionalStringifiedId(rawTarget.threadId);
if (target.kind === "user") {
const account = resolveMatrixAccount({
cfg: this.opts.cfg as CoreConfig,
accountId: this.opts.accountId,
});
const repaired = await this.repairDirectRooms({
client: this.opts.client,
remoteUserId: target.id,
encrypted: account.config.encryption === true,
});
if (!repaired.activeRoomId) {
return null;
}
return {
to: `room:${repaired.activeRoomId}`,
roomId: repaired.activeRoomId,
threadId,
};
}
return {
to: `room:${target.id}`,
roomId: target.id,
threadId,
};
}
private async finalizeResolved(
request: ApprovalRequest,
resolved: ApprovalResolved,
entries: PendingMessage[],
): Promise<void> {
const text = buildResolvedApprovalText({ request, resolved });
await Promise.allSettled(
entries.map(async (entry) => {
this.untrackReactionTarget({
roomId: entry.roomId,
eventId: entry.reactionEventId,
});
const [primaryMessageId, ...staleMessageIds] = normalizePendingMessageIds(entry);
if (!primaryMessageId) {
return;
}
await Promise.allSettled([
this.editMessage(entry.roomId, primaryMessageId, text, {
cfg: this.opts.cfg as CoreConfig,
accountId: this.opts.accountId,
client: this.opts.client,
}),
...staleMessageIds.map(async (messageId) => {
await this.deleteMessage(entry.roomId, messageId, {
cfg: this.opts.cfg as CoreConfig,
accountId: this.opts.accountId,
client: this.opts.client,
reason: "approval resolved",
});
}),
]);
}),
);
}
private async clearPending(entries: PendingMessage[]): Promise<void> {
await Promise.allSettled(
entries.map(async (entry) => {
this.untrackReactionTarget({
roomId: entry.roomId,
eventId: entry.reactionEventId,
});
await Promise.allSettled(
normalizePendingMessageIds(entry).map(async (messageId) => {
await this.deleteMessage(entry.roomId, messageId, {
cfg: this.opts.cfg as CoreConfig,
accountId: this.opts.accountId,
client: this.opts.client,
reason: "approval expired",
});
}),
);
}),
);
}
private trackReactionTarget(
params: ReactionTargetRef & {
approvalId: string;
allowedDecisions: readonly ExecApprovalReplyDecision[];
},
): void {
const normalized = normalizeReactionTargetRef(params);
const key = normalized ? buildReactionTargetRefKey(normalized) : null;
if (!normalized || !key) {
return;
}
registerMatrixApprovalReactionTarget({
roomId: normalized.roomId,
eventId: normalized.eventId,
approvalId: params.approvalId,
allowedDecisions: params.allowedDecisions,
});
this.trackedReactionTargets.set(key, normalized);
}
private untrackReactionTarget(params: ReactionTargetRef): void {
const normalized = normalizeReactionTargetRef(params);
const key = normalized ? buildReactionTargetRefKey(normalized) : null;
if (!normalized || !key) {
return;
}
unregisterMatrixApprovalReactionTarget(normalized);
this.trackedReactionTargets.delete(key);
}
private clearTrackedReactionTargets(): void {
for (const target of this.trackedReactionTargets.values()) {
unregisterMatrixApprovalReactionTarget(target);
}
this.trackedReactionTargets.clear();
}
}

View File

@@ -202,7 +202,7 @@ describe("matrix exec approvals", () => {
).toBe(true);
});
it("does not suppress local prompts for plugin approval payloads", () => {
it("suppresses local prompts for plugin approval payloads when DM approvers are configured", () => {
const payload = {
channelData: {
execApproval: {
@@ -215,10 +215,13 @@ describe("matrix exec approvals", () => {
expect(
shouldSuppressLocalMatrixExecApprovalPrompt({
cfg: buildConfig({ enabled: true, approvers: ["@owner:example.org"] }),
cfg: buildConfig(
{ enabled: true, approvers: ["@owner:example.org"] },
{ dm: { allowFrom: ["@owner:example.org"] } },
),
payload,
}),
).toBe(false);
).toBe(true);
});
it("normalizes prefixed approver ids", () => {

View File

@@ -12,15 +12,15 @@ import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { getMatrixApprovalAuthApprovers } from "./approval-auth.js";
import { normalizeMatrixApproverId } from "./approval-ids.js";
import { listMatrixAccountIds, resolveMatrixAccount } from "./matrix/accounts.js";
import { normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
import type { CoreConfig } from "./types.js";
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type ApprovalKind = "exec" | "plugin";
export function normalizeMatrixApproverId(value: string | number): string | undefined {
const normalized = normalizeMatrixUserId(String(value));
return normalized || undefined;
}
export { normalizeMatrixApproverId };
function normalizeMatrixExecApproverId(value: string | number): string | undefined {
const normalized = normalizeMatrixApproverId(value);
@@ -45,6 +45,7 @@ function resolveMatrixExecApprovalConfig(params: {
function countMatrixExecApprovalEligibleAccounts(params: {
cfg: OpenClawConfig;
request: ApprovalRequest;
approvalKind: ApprovalKind;
}): number {
return listMatrixAccountIds(params.cfg).filter((accountId) => {
const account = resolveMatrixAccount({ cfg: params.cfg, accountId });
@@ -67,7 +68,11 @@ function countMatrixExecApprovalEligibleAccounts(params: {
return (
isChannelExecApprovalClientEnabledFromConfig({
enabled: config?.enabled,
approverCount: getMatrixExecApprovalApprovers({ cfg: params.cfg, accountId }).length,
approverCount: getMatrixApprovalApprovers({
cfg: params.cfg,
accountId,
approvalKind: params.approvalKind,
}).length,
}) &&
matchesApprovalRequestFilters({
request: params.request.request,
@@ -82,6 +87,7 @@ function matchesMatrixRequestAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
request: ApprovalRequest;
approvalKind: ApprovalKind;
}): boolean {
const turnSourceChannel = normalizeLowercaseStringOrEmpty(
params.request.request.turnSourceChannel,
@@ -96,6 +102,7 @@ function matchesMatrixRequestAccount(params: {
countMatrixExecApprovalEligibleAccounts({
cfg: params.cfg,
request: params.request,
approvalKind: params.approvalKind,
}) <= 1
);
}
@@ -118,6 +125,24 @@ export function getMatrixExecApprovalApprovers(params: {
});
}
function resolveMatrixApprovalKind(request: ApprovalRequest): ApprovalKind {
return request.id.startsWith("plugin:") ? "plugin" : "exec";
}
export function getMatrixApprovalApprovers(params: {
cfg: OpenClawConfig;
accountId?: string | null;
approvalKind: ApprovalKind;
}): string[] {
if (params.approvalKind === "plugin") {
return getMatrixApprovalAuthApprovers({
cfg: params.cfg as CoreConfig,
accountId: params.accountId,
});
}
return getMatrixExecApprovalApprovers(params);
}
export function isMatrixExecApprovalTargetRecipient(params: {
cfg: OpenClawConfig;
senderId?: string | null;
@@ -137,7 +162,11 @@ const matrixExecApprovalProfile = createChannelExecApprovalProfile({
resolveApprovers: getMatrixExecApprovalApprovers,
normalizeSenderId: normalizeMatrixApproverId,
isTargetRecipient: isMatrixExecApprovalTargetRecipient,
matchesRequestAccount: matchesMatrixRequestAccount,
matchesRequestAccount: (params) =>
matchesMatrixRequestAccount({
...params,
approvalKind: "exec",
}),
});
export const isMatrixExecApprovalClientEnabled = matrixExecApprovalProfile.isClientEnabled;
@@ -146,9 +175,86 @@ export const isMatrixExecApprovalAuthorizedSender = matrixExecApprovalProfile.is
export const resolveMatrixExecApprovalTarget = matrixExecApprovalProfile.resolveTarget;
export const shouldHandleMatrixExecApprovalRequest = matrixExecApprovalProfile.shouldHandleRequest;
export function isMatrixApprovalClientEnabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
approvalKind: ApprovalKind;
}): boolean {
if (params.approvalKind === "exec") {
return isMatrixExecApprovalClientEnabled(params);
}
const config = resolveMatrixExecApprovalConfig(params);
return isChannelExecApprovalClientEnabledFromConfig({
enabled: config?.enabled,
approverCount: getMatrixApprovalApprovers(params).length,
});
}
export function isMatrixAnyApprovalClientEnabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): boolean {
return (
isMatrixApprovalClientEnabled({
...params,
approvalKind: "exec",
}) ||
isMatrixApprovalClientEnabled({
...params,
approvalKind: "plugin",
})
);
}
export function shouldHandleMatrixApprovalRequest(params: {
cfg: OpenClawConfig;
accountId?: string | null;
request: ApprovalRequest;
}): boolean {
const approvalKind = resolveMatrixApprovalKind(params.request);
if (
!matchesMatrixRequestAccount({
...params,
approvalKind,
})
) {
return false;
}
const config = resolveMatrixExecApprovalConfig(params);
if (
!isChannelExecApprovalClientEnabledFromConfig({
enabled: config?.enabled,
approverCount: getMatrixApprovalApprovers({
...params,
approvalKind,
}).length,
})
) {
return false;
}
return matchesApprovalRequestFilters({
request: params.request.request,
agentFilter: config?.agentFilter,
sessionFilter: config?.sessionFilter,
});
}
function buildFilterCheckRequest(params: {
metadata: NonNullable<ReturnType<typeof getExecApprovalReplyMetadata>>;
}): ExecApprovalRequest {
}): ApprovalRequest {
if (params.metadata.approvalKind === "plugin") {
return {
id: params.metadata.approvalId,
request: {
title: "Plugin Approval Required",
description: "",
agentId: params.metadata.agentId ?? null,
sessionKey: params.metadata.sessionKey ?? null,
},
createdAtMs: 0,
expiresAtMs: 0,
};
}
return {
id: params.metadata.approvalId,
request: {
@@ -173,13 +279,10 @@ export function shouldSuppressLocalMatrixExecApprovalPrompt(params: {
if (!metadata) {
return false;
}
if (metadata.approvalKind !== "exec") {
return false;
}
const request = buildFilterCheckRequest({
metadata,
});
return shouldHandleMatrixExecApprovalRequest({
return shouldHandleMatrixApprovalRequest({
cfg: params.cfg,
accountId: params.accountId,
request,

View File

@@ -1,5 +1,6 @@
import { format } from "node:util";
import { MatrixExecApprovalHandler } from "../../exec-approvals-handler.js";
import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY } from "openclaw/plugin-sdk/approval-handler-runtime";
import { registerChannelRuntimeContext } from "openclaw/plugin-sdk/channel-runtime-context";
import {
GROUP_POLICY_BLOCKED_LABEL,
resolveThreadBindingIdleTimeoutMsForChannel,
@@ -35,6 +36,7 @@ import { runMatrixStartupMaintenance } from "./startup.js";
export type MonitorMatrixOpts = {
runtime?: RuntimeEnv;
channelRuntime?: import("openclaw/plugin-sdk/channel-core").PluginRuntime["channel"];
abortSignal?: AbortSignal;
mediaMaxMb?: number;
initialSyncLimit?: number;
@@ -147,7 +149,6 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
setActiveMatrixClient(client, auth.accountId);
let cleanedUp = false;
let threadBindingManager: { accountId: string; stop: () => void } | null = null;
let execApprovalsHandler: MatrixExecApprovalHandler | null = null;
const inboundDeduper = await createMatrixInboundEventDeduper({
auth,
env: process.env,
@@ -167,7 +168,6 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
client.stopSyncWithoutPersist();
await client.drainPendingDecryptions("matrix monitor shutdown");
await waitForInFlightRoomMessages();
await execApprovalsHandler?.stop();
threadBindingManager?.stop();
await inboundDeduper.stop();
await releaseSharedClientInstance(client, "persist");
@@ -360,12 +360,16 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
logVerboseMessage(`matrix: failed to backfill deviceId after startup (${String(err)})`);
});
execApprovalsHandler = new MatrixExecApprovalHandler({
client,
registerChannelRuntimeContext({
channelRuntime: opts.channelRuntime,
channelId: "matrix",
accountId: effectiveAccountId,
cfg,
capability: CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY,
context: {
client,
},
abortSignal: opts.abortSignal,
});
await execApprovalsHandler.start();
await runMatrixStartupMaintenance({
client,

View File

@@ -7,16 +7,16 @@ import {
import type { CoreConfig } from "../../types.js";
import { handleInboundMatrixReaction } from "./reaction-events.js";
const resolveMatrixExecApproval = vi.fn();
const resolveMatrixApproval = vi.fn();
vi.mock("../../exec-approval-resolver.js", () => ({
isApprovalNotFoundError: (err: unknown) =>
err instanceof Error && /unknown or expired approval id/i.test(err.message),
resolveMatrixExecApproval: (...args: unknown[]) => resolveMatrixExecApproval(...args),
resolveMatrixApproval: (...args: unknown[]) => resolveMatrixApproval(...args),
}));
beforeEach(() => {
resolveMatrixExecApproval.mockReset();
resolveMatrixApproval.mockReset();
clearMatrixApprovalReactionTargetsForTest();
});
@@ -97,7 +97,7 @@ describe("matrix approval reactions", () => {
logVerboseMessage: vi.fn(),
});
expect(resolveMatrixExecApproval).toHaveBeenCalledWith({
expect(resolveMatrixApproval).toHaveBeenCalledWith({
cfg: buildConfig(),
approvalId: "req-123",
decision: "allow-once",
@@ -142,7 +142,7 @@ describe("matrix approval reactions", () => {
logVerboseMessage: vi.fn(),
});
expect(resolveMatrixExecApproval).not.toHaveBeenCalled();
expect(resolveMatrixApproval).not.toHaveBeenCalled();
expect(core.system.enqueueSystemEvent).toHaveBeenCalledWith(
"Matrix reaction added: 👍 by Owner on msg $msg-1",
expect.objectContaining({
@@ -197,7 +197,7 @@ describe("matrix approval reactions", () => {
logVerboseMessage: vi.fn(),
});
expect(resolveMatrixExecApproval).toHaveBeenCalledWith({
expect(resolveMatrixApproval).toHaveBeenCalledWith({
cfg,
approvalId: "req-123",
decision: "deny",
@@ -243,7 +243,7 @@ describe("matrix approval reactions", () => {
});
expect(client.getEvent).not.toHaveBeenCalled();
expect(resolveMatrixExecApproval).toHaveBeenCalledWith({
expect(resolveMatrixApproval).toHaveBeenCalledWith({
cfg: buildConfig(),
approvalId: "req-123",
decision: "allow-once",
@@ -252,9 +252,61 @@ describe("matrix approval reactions", () => {
expect(core.system.enqueueSystemEvent).not.toHaveBeenCalled();
});
it("resolves plugin approval reactions through the same Matrix reaction path", async () => {
const core = buildCore();
const cfg = buildConfig();
const matrixCfg = cfg.channels?.matrix;
if (!matrixCfg) {
throw new Error("matrix config missing");
}
matrixCfg.dm = { allowFrom: ["@owner:example.org"] };
registerMatrixApprovalReactionTarget({
roomId: "!ops:example.org",
eventId: "$plugin-approval-msg",
approvalId: "plugin:req-123",
allowedDecisions: ["allow-once", "deny"],
});
const client = {
getEvent: vi.fn(),
} as unknown as Parameters<typeof handleInboundMatrixReaction>[0]["client"];
await handleInboundMatrixReaction({
client,
core,
cfg,
accountId: "default",
roomId: "!ops:example.org",
event: {
event_id: "$reaction-1",
origin_server_ts: 123,
content: {
"m.relates_to": {
rel_type: "m.annotation",
event_id: "$plugin-approval-msg",
key: "✅",
},
},
} as never,
senderId: "@owner:example.org",
senderLabel: "Owner",
selfUserId: "@bot:example.org",
isDirectMessage: false,
logVerboseMessage: vi.fn(),
});
expect(client.getEvent).not.toHaveBeenCalled();
expect(resolveMatrixApproval).toHaveBeenCalledWith({
cfg,
approvalId: "plugin:req-123",
decision: "allow-once",
senderId: "@owner:example.org",
});
expect(core.system.enqueueSystemEvent).not.toHaveBeenCalled();
});
it("unregisters stale approval anchors after not-found resolution", async () => {
const core = buildCore();
resolveMatrixExecApproval.mockRejectedValueOnce(
resolveMatrixApproval.mockRejectedValueOnce(
new Error("unknown or expired approval id req-123"),
);
registerMatrixApprovalReactionTarget({
@@ -338,7 +390,7 @@ describe("matrix approval reactions", () => {
});
expect(client.getEvent).not.toHaveBeenCalled();
expect(resolveMatrixExecApproval).not.toHaveBeenCalled();
expect(resolveMatrixApproval).not.toHaveBeenCalled();
expect(core.system.enqueueSystemEvent).not.toHaveBeenCalled();
});
});

View File

@@ -1,13 +1,10 @@
import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime";
import { matrixApprovalCapability } from "../../approval-native.js";
import {
resolveMatrixApprovalReactionTarget,
unregisterMatrixApprovalReactionTarget,
} from "../../approval-reactions.js";
import {
isApprovalNotFoundError,
resolveMatrixExecApproval,
} from "../../exec-approval-resolver.js";
import { isMatrixExecApprovalAuthorizedSender } from "../../exec-approvals.js";
import { isApprovalNotFoundError, resolveMatrixApproval } from "../../exec-approval-resolver.js";
import type { CoreConfig } from "../../types.js";
import { resolveMatrixAccountConfig } from "../account-config.js";
import { extractMatrixReactionAnnotation } from "../reaction-common.js";
@@ -44,16 +41,18 @@ async function maybeResolveMatrixApprovalReaction(params: {
return false;
}
if (
!isMatrixExecApprovalAuthorizedSender({
!matrixApprovalCapability.authorizeActorAction?.({
cfg: params.cfg,
accountId: params.accountId,
senderId: params.senderId,
})
action: "approve",
approvalKind: params.target.approvalId.startsWith("plugin:") ? "plugin" : "exec",
})?.authorized
) {
return false;
}
try {
await resolveMatrixExecApproval({
await resolveMatrixApproval({
cfg: params.cfg,
approvalId: params.target.approvalId,
decision: params.target.decision,

View File

@@ -292,7 +292,7 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
isConfigured: isMattermostConfigured,
describeAccount: describeMattermostAccount,
},
auth: mattermostApprovalAuth,
approvalCapability: mattermostApprovalAuth,
doctor: mattermostDoctor,
groups: {
resolveRequireMention: resolveMattermostGroupRequireMention,

View File

@@ -149,7 +149,7 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
configured: account.configured,
}),
},
auth: msTeamsApprovalAuth,
approvalCapability: msTeamsApprovalAuth,
doctor: {
dmAllowFromMode: "topOnly",
groupModel: "hybrid",

View File

@@ -94,7 +94,7 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
},
}),
},
auth: nextcloudTalkApprovalAuth,
approvalCapability: nextcloudTalkApprovalAuth,
doctor: nextcloudTalkDoctor,
groups: {
resolveRequireMention: ({ cfg, accountId, groupId }) => {

View File

@@ -249,7 +249,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount, SignalProbe> =
setup: signalSetupAdapter,
}),
actions: signalMessageActions,
auth: signalApprovalAuth,
approvalCapability: signalApprovalAuth,
allowlist: buildDmGroupAccountAllowlistAdapter({
channelId: "signal",
resolveAccount: resolveSignalAccount,

View File

@@ -0,0 +1,331 @@
import type { App } from "@slack/bolt";
import type { Block, KnownBlock } from "@slack/web-api";
import type {
ChannelApprovalCapabilityHandlerContext,
ExecApprovalExpiredView,
ExecApprovalPendingView,
ExecApprovalResolvedView,
} from "openclaw/plugin-sdk/approval-handler-runtime";
import { createChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approval-native-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
buildApprovalInteractiveReplyFromActionDescriptors,
type ExecApprovalRequest,
} from "openclaw/plugin-sdk/infra-runtime";
import { logError } from "openclaw/plugin-sdk/text-runtime";
import { slackNativeApprovalAdapter } from "./approval-native.js";
import {
isSlackExecApprovalClientEnabled,
normalizeSlackApproverId,
shouldHandleSlackExecApprovalRequest,
} from "./exec-approvals.js";
import { resolveSlackReplyBlocks } from "./reply-blocks.js";
import { sendMessageSlack } from "./send.js";
type SlackBlock = Block | KnownBlock;
type SlackPendingApproval = {
channelId: string;
messageTs: string;
};
type SlackPendingDelivery = {
text: string;
blocks: SlackBlock[];
};
type SlackExecApprovalConfig = NonNullable<
NonNullable<NonNullable<OpenClawConfig["channels"]>["slack"]>["execApprovals"]
>;
export type SlackApprovalHandlerContext = {
app: App;
config: SlackExecApprovalConfig;
};
function resolveHandlerContext(params: ChannelApprovalCapabilityHandlerContext): {
accountId: string;
context: SlackApprovalHandlerContext;
} | null {
const context = params.context as SlackApprovalHandlerContext | undefined;
const accountId = params.accountId?.trim() || "";
if (!context?.app || !accountId) {
return null;
}
return { accountId, context };
}
function truncateSlackMrkdwn(text: string, maxChars: number): string {
return text.length <= maxChars ? text : `${text.slice(0, maxChars - 1)}`;
}
function buildSlackCodeBlock(text: string): string {
let fence = "```";
while (text.includes(fence)) {
fence += "`";
}
return `${fence}\n${text}\n${fence}`;
}
function formatSlackApprover(resolvedBy?: string | null): string | null {
const normalized = resolvedBy ? normalizeSlackApproverId(resolvedBy) : undefined;
if (normalized) {
return `<@${normalized}>`;
}
const trimmed = resolvedBy?.trim();
return trimmed ? trimmed : null;
}
function formatSlackMetadataLine(label: string, value: string): string {
return `*${label}:* ${value}`;
}
function buildSlackMetadataLines(metadata: readonly { label: string; value: string }[]): string[] {
return metadata.map((item) => formatSlackMetadataLine(item.label, item.value));
}
function resolveSlackApprovalDecisionLabel(
decision: "allow-once" | "allow-always" | "deny",
): string {
return decision === "allow-once"
? "Allowed once"
: decision === "allow-always"
? "Allowed always"
: "Denied";
}
function buildSlackPendingApprovalText(view: ExecApprovalPendingView): string {
const metadataLines = buildSlackMetadataLines(view.metadata);
const lines = [
"*Exec approval required*",
"A command needs your approval.",
"",
"*Command*",
buildSlackCodeBlock(view.commandText),
...metadataLines,
];
return lines.filter(Boolean).join("\n");
}
function buildSlackPendingApprovalBlocks(view: ExecApprovalPendingView): SlackBlock[] {
const metadataLines = buildSlackMetadataLines(view.metadata);
const interactiveBlocks =
resolveSlackReplyBlocks({
text: "",
interactive: buildApprovalInteractiveReplyFromActionDescriptors(view.actions),
}) ?? [];
return [
{
type: "section",
text: {
type: "mrkdwn",
text: "*Exec approval required*\nA command needs your approval.",
},
},
{
type: "section",
text: {
type: "mrkdwn",
text: `*Command*\n${buildSlackCodeBlock(truncateSlackMrkdwn(view.commandText, 2600))}`,
},
},
...(metadataLines.length > 0
? [
{
type: "context",
elements: metadataLines.map((line) => ({
type: "mrkdwn" as const,
text: line,
})),
} satisfies SlackBlock,
]
: []),
...interactiveBlocks,
];
}
function buildSlackResolvedText(view: ExecApprovalResolvedView): string {
const resolvedBy = formatSlackApprover(view.resolvedBy);
const lines = [
`*Exec approval: ${resolveSlackApprovalDecisionLabel(view.decision)}*`,
resolvedBy ? `Resolved by ${resolvedBy}.` : "Resolved.",
"",
"*Command*",
buildSlackCodeBlock(view.commandText),
];
return lines.join("\n");
}
function buildSlackResolvedBlocks(view: ExecApprovalResolvedView): SlackBlock[] {
const resolvedBy = formatSlackApprover(view.resolvedBy);
return [
{
type: "section",
text: {
type: "mrkdwn",
text: `*Exec approval: ${resolveSlackApprovalDecisionLabel(view.decision)}*\n${
resolvedBy ? `Resolved by ${resolvedBy}.` : "Resolved."
}`,
},
},
{
type: "section",
text: {
type: "mrkdwn",
text: `*Command*\n${buildSlackCodeBlock(truncateSlackMrkdwn(view.commandText, 2600))}`,
},
},
];
}
function buildSlackExpiredText(view: ExecApprovalExpiredView): string {
return [
"*Exec approval expired*",
"This approval request expired before it was resolved.",
"",
"*Command*",
buildSlackCodeBlock(view.commandText),
].join("\n");
}
function buildSlackExpiredBlocks(view: ExecApprovalExpiredView): SlackBlock[] {
return [
{
type: "section",
text: {
type: "mrkdwn",
text: "*Exec approval expired*\nThis approval request expired before it was resolved.",
},
},
{
type: "section",
text: {
type: "mrkdwn",
text: `*Command*\n${buildSlackCodeBlock(truncateSlackMrkdwn(view.commandText, 2600))}`,
},
},
];
}
async function updateMessage(params: {
app: App;
channelId: string;
messageTs: string;
text: string;
blocks: SlackBlock[];
}): Promise<void> {
try {
await params.app.client.chat.update({
channel: params.channelId,
ts: params.messageTs,
text: params.text,
blocks: params.blocks,
});
} catch (err) {
logError(`slack exec approvals: failed to update message: ${String(err)}`);
}
}
export const slackApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter<
SlackPendingDelivery,
{ to: string; threadTs?: string },
SlackPendingApproval,
never
>({
eventKinds: ["exec"],
availability: {
isConfigured: (params) => {
const resolved = resolveHandlerContext(params);
return resolved
? isSlackExecApprovalClientEnabled({
cfg: params.cfg,
accountId: resolved.accountId,
})
: false;
},
shouldHandle: (params) => {
const resolved = resolveHandlerContext(params);
if (!resolved) {
return false;
}
return (
shouldHandleSlackExecApprovalRequest({
cfg: params.cfg,
accountId: resolved.accountId,
request: params.request as ExecApprovalRequest,
}) &&
slackNativeApprovalAdapter.native?.describeDeliveryCapabilities({
cfg: params.cfg,
accountId: resolved.accountId,
approvalKind: "exec",
request: params.request as ExecApprovalRequest,
}).enabled === true
);
},
},
presentation: {
buildPendingPayload: ({ view }) => ({
text: buildSlackPendingApprovalText(view as ExecApprovalPendingView),
blocks: buildSlackPendingApprovalBlocks(view as ExecApprovalPendingView),
}),
buildResolvedResult: ({ view }) => ({
kind: "update",
payload: {
text: buildSlackResolvedText(view as ExecApprovalResolvedView),
blocks: buildSlackResolvedBlocks(view as ExecApprovalResolvedView),
},
}),
buildExpiredResult: ({ view }) => ({
kind: "update",
payload: {
text: buildSlackExpiredText(view as ExecApprovalExpiredView),
blocks: buildSlackExpiredBlocks(view as ExecApprovalExpiredView),
},
}),
},
transport: {
prepareTarget: ({ plannedTarget }) => ({
dedupeKey: buildChannelApprovalNativeTargetKey(plannedTarget.target),
target: {
to: plannedTarget.target.to,
threadTs:
plannedTarget.target.threadId != null ? String(plannedTarget.target.threadId) : undefined,
},
}),
deliverPending: async ({ cfg, accountId, context, preparedTarget, pendingPayload }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return null;
}
const message = await sendMessageSlack(preparedTarget.to, pendingPayload.text, {
cfg,
accountId: resolved.accountId,
threadTs: preparedTarget.threadTs,
blocks: pendingPayload.blocks,
client: resolved.context.app.client,
});
return {
channelId: message.channelId,
messageTs: message.messageId,
};
},
updateEntry: async ({ cfg, accountId, context, entry, payload }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return;
}
const nextPayload = payload as SlackPendingDelivery;
await updateMessage({
app: resolved.context.app,
channelId: entry.channelId,
messageTs: entry.messageTs,
text: nextPayload.text,
blocks: nextPayload.blocks,
});
},
},
observe: {
onDeliveryError: ({ error, request }) => {
logError(`slack exec approvals: failed to deliver approval ${request.id}: ${String(error)}`);
},
},
});

View File

@@ -232,7 +232,33 @@ describe("slack native approval adapter", () => {
expect(target).toEqual({
to: "channel:C123",
threadId: "1712345678",
threadId: "1712345678.123456",
});
});
it("falls back to the session-key origin target for plugin approvals when the store is missing", async () => {
const target = await slackNativeApprovalAdapter.native?.resolveOriginTarget?.({
cfg: {
...buildConfig(),
session: { store: STORE_PATH },
},
accountId: "default",
approvalKind: "plugin",
request: {
id: "plugin:req-1",
request: {
title: "Plugin approval",
description: "Allow access",
sessionKey: "agent:main:slack:channel:c123:thread:1712345678.123456",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(target).toEqual({
to: "channel:C123",
threadId: "1712345678.123456",
});
});

View File

@@ -2,9 +2,11 @@ import {
createApproverRestrictedNativeApprovalCapability,
splitChannelApprovalCapability,
} from "openclaw/plugin-sdk/approval-delivery-runtime";
import { createLazyChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
import {
createChannelApproverDmTargetResolver,
createChannelNativeOriginTargetResolver,
resolveApprovalRequestSessionConversation,
} from "openclaw/plugin-sdk/approval-native-runtime";
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
import {
@@ -32,9 +34,7 @@ function extractSlackSessionKind(
return null;
}
const match = sessionKey.match(/slack:(direct|channel|group):/i);
return match?.[1]
? (normalizeLowercaseStringOrEmpty(match[1]) as "direct" | "channel" | "group")
: null;
return match?.[1] ? (match[1].toLowerCase() as "direct" | "channel" | "group") : null;
}
function normalizeComparableTarget(value: string): string {
@@ -83,13 +83,33 @@ function resolveSessionSlackOriginTarget(sessionTarget: {
to: sessionTarget.to,
threadId:
typeof sessionTarget.threadId === "string"
? sessionTarget.threadId
? sessionTarget.threadId.trim() || undefined
: typeof sessionTarget.threadId === "number"
? String(sessionTarget.threadId)
: undefined,
};
}
function resolveSlackFallbackOriginTarget(request: ApprovalRequest): SlackOriginTarget | null {
const sessionTarget = resolveApprovalRequestSessionConversation({
request,
channel: "slack",
});
if (!sessionTarget) {
return null;
}
const parsed = parseSlackTarget(sessionTarget.id.toUpperCase(), {
defaultKind: "channel",
});
if (!parsed) {
return null;
}
return {
to: `${parsed.kind}:${parsed.id}`,
threadId: sessionTarget.threadId,
};
}
function slackTargetsMatch(a: SlackOriginTarget, b: SlackOriginTarget): boolean {
return (
normalizeComparableTarget(a.to) === normalizeComparableTarget(b.to) &&
@@ -108,6 +128,7 @@ const resolveSlackOriginTarget = createChannelNativeOriginTargetResolver({
resolveTurnSourceTarget: resolveTurnSourceSlackOriginTarget,
resolveSessionTarget: resolveSessionSlackOriginTarget,
targetsMatch: slackTargetsMatch,
resolveFallbackTarget: resolveSlackFallbackOriginTarget,
});
const resolveSlackApproverDmTargets = createChannelApproverDmTargetResolver({
@@ -149,6 +170,21 @@ export const slackApprovalCapability = createApproverRestrictedNativeApprovalCap
resolveOriginTarget: resolveSlackOriginTarget,
resolveApproverDmTargets: resolveSlackApproverDmTargets,
notifyOriginWhenDmOnly: true,
nativeRuntime: createLazyChannelApprovalNativeRuntimeAdapter({
eventKinds: ["exec"],
isConfigured: ({ cfg, accountId }) =>
isSlackExecApprovalClientEnabled({
cfg,
accountId,
}),
shouldHandle: ({ cfg, accountId, request }) =>
shouldHandleSlackExecApprovalRequest({
cfg,
accountId,
request,
}),
load: async () => (await import("./approval-handler.runtime.js")).slackApprovalNativeRuntime,
}),
});
export const slackNativeApprovalAdapter = splitChannelApprovalCapability(slackApprovalCapability);

View File

@@ -494,6 +494,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
accountId: account.accountId,
config: ctx.cfg,
runtime: ctx.runtime,
channelRuntime: ctx.channelRuntime,
abortSignal: ctx.abortSignal,
mediaMaxMb: account.config.mediaMaxMb,
slashCommand: account.config.slashCommand,

View File

@@ -1,234 +0,0 @@
import type { App } from "@slack/bolt";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const sendMessageSlackMock = vi.hoisted(() => vi.fn());
vi.mock("../send.js", () => ({
sendMessageSlack: sendMessageSlackMock,
}));
let SlackExecApprovalHandler: typeof import("./exec-approvals.js").SlackExecApprovalHandler;
function buildConfig(
target: "dm" | "channel" | "both" = "dm",
slackOverrides?: Partial<NonNullable<NonNullable<OpenClawConfig["channels"]>["slack"]>>,
): OpenClawConfig {
const configuredExecApprovals = slackOverrides?.execApprovals;
return {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
...slackOverrides,
execApprovals: configuredExecApprovals ?? {
enabled: true,
approvers: ["U123APPROVER"],
target,
},
},
},
} as OpenClawConfig;
}
function buildApp(): App {
return {
client: {
chat: {
update: vi.fn().mockResolvedValue(undefined),
},
},
} as unknown as App;
}
function buildRequest(overrides?: Partial<Record<string, unknown>>) {
return {
id: "req-1",
request: {
command: "python3 -c \"print('slack exec approval smoke')\"",
turnSourceChannel: "slack",
turnSourceTo: "channel:C123ROOM",
turnSourceAccountId: "default",
turnSourceThreadId: "1712345678.123456",
sessionKey: "agent:main:slack:channel:c123room:thread:1712345678.123456",
...overrides,
},
createdAtMs: 0,
expiresAtMs: Date.now() + 60_000,
};
}
describe("SlackExecApprovalHandler", () => {
beforeAll(async () => {
({ SlackExecApprovalHandler } = await import("./exec-approvals.js"));
});
beforeEach(() => {
sendMessageSlackMock.mockReset();
sendMessageSlackMock.mockResolvedValue({
messageId: "1712345678.999999",
channelId: "D123APPROVER",
});
});
it("delivers DM-first approvals and only posts a short origin notice", async () => {
const app = buildApp();
const handler = new SlackExecApprovalHandler({
app,
accountId: "default",
config: buildConfig("dm").channels!.slack!.execApprovals!,
cfg: buildConfig("dm"),
});
await handler.handleApprovalRequested(buildRequest());
expect(sendMessageSlackMock).toHaveBeenCalledTimes(2);
expect(sendMessageSlackMock).toHaveBeenNthCalledWith(
1,
"channel:C123ROOM",
"Approval required. I sent approval DMs to the approvers for this account.",
expect.objectContaining({
accountId: "default",
threadTs: "1712345678.123456",
}),
);
expect(sendMessageSlackMock).toHaveBeenNthCalledWith(
2,
"user:U123APPROVER",
expect.stringContaining("Exec approval required"),
expect.objectContaining({
accountId: "default",
blocks: expect.arrayContaining([expect.objectContaining({ type: "actions" })]),
}),
);
});
it("does not post a redundant DM redirect notice when the origin is already the approver DM", async () => {
const app = buildApp();
const handler = new SlackExecApprovalHandler({
app,
accountId: "default",
config: buildConfig("dm").channels!.slack!.execApprovals!,
cfg: buildConfig("dm"),
});
await handler.handleApprovalRequested(
buildRequest({
turnSourceTo: "user:U123APPROVER",
turnSourceThreadId: undefined,
sessionKey: "agent:main:slack:direct:U123APPROVER",
}),
);
expect(sendMessageSlackMock).toHaveBeenCalledTimes(1);
expect(sendMessageSlackMock).toHaveBeenCalledWith(
"user:U123APPROVER",
expect.stringContaining("Exec approval required"),
expect.objectContaining({
blocks: expect.arrayContaining([expect.objectContaining({ type: "actions" })]),
}),
);
});
it("omits allow-always when exec approvals disallow it", async () => {
const app = buildApp();
const handler = new SlackExecApprovalHandler({
app,
accountId: "default",
config: buildConfig("dm").channels!.slack!.execApprovals!,
cfg: buildConfig("dm"),
});
await handler.handleApprovalRequested(
buildRequest({
ask: "always",
allowedDecisions: ["allow-once", "deny"],
}),
);
const dmCall = sendMessageSlackMock.mock.calls.find(([to]) => to === "user:U123APPROVER");
const blocks = dmCall?.[2]?.blocks as Array<Record<string, unknown>> | undefined;
const actionsBlock = blocks?.find((block) => block.type === "actions");
const buttons = Array.isArray(actionsBlock?.elements) ? actionsBlock.elements : [];
const buttonTexts = buttons.map((button) =>
typeof button === "object" && button && typeof button.text === "object" && button.text
? typeof (button.text as { text?: unknown }).text === "string"
? (button.text as { text: string }).text
: ""
: "",
);
expect(buttonTexts).toContain("Allow Once");
expect(buttonTexts).toContain("Deny");
expect(buttonTexts).not.toContain("Allow Always");
});
it("updates the pending approval card in place after resolution", async () => {
const app = buildApp();
const update = app.client.chat.update as ReturnType<typeof vi.fn>;
const handler = new SlackExecApprovalHandler({
app,
accountId: "default",
config: buildConfig("dm").channels!.slack!.execApprovals!,
cfg: buildConfig("dm"),
});
await handler.handleApprovalRequested(
buildRequest({
turnSourceTo: "user:U123APPROVER",
turnSourceThreadId: undefined,
sessionKey: "agent:main:slack:direct:U123APPROVER",
}),
);
await handler.handleApprovalResolved({
id: "req-1",
decision: "allow-once",
resolvedBy: "U123APPROVER",
request: buildRequest().request,
ts: Date.now(),
});
expect(update).toHaveBeenCalledWith(
expect.objectContaining({
channel: "D123APPROVER",
ts: "1712345678.999999",
text: expect.stringContaining("Exec approval: Allowed once"),
blocks: expect.not.arrayContaining([expect.objectContaining({ type: "actions" })]),
}),
);
});
it("does not treat allowFrom senders as approvers", async () => {
const app = buildApp();
const cfg = buildConfig("dm", {
allowFrom: ["U123APPROVER"],
execApprovals: { enabled: true, target: "dm" },
});
const handler = new SlackExecApprovalHandler({
app,
accountId: "default",
config: cfg.channels!.slack!.execApprovals!,
cfg,
});
expect(handler.shouldHandle(buildRequest())).toBe(false);
});
it("accepts commands.ownerAllowFrom as exec approver fallback", async () => {
const app = buildApp();
const cfg = {
...buildConfig("dm", {
execApprovals: { enabled: true, target: "dm" },
}),
commands: { ownerAllowFrom: ["slack:U123APPROVER"] },
} as OpenClawConfig;
const handler = new SlackExecApprovalHandler({
app,
accountId: "default",
config: cfg.channels!.slack!.execApprovals!,
cfg,
});
expect(handler.shouldHandle(buildRequest())).toBe(true);
});
});

View File

@@ -1,393 +0,0 @@
import type { App } from "@slack/bolt";
import type { Block, KnownBlock } from "@slack/web-api";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
buildApprovalInteractiveReply,
createChannelNativeApprovalRuntime,
getExecApprovalApproverDmNoticeText,
resolveExecApprovalCommandDisplay,
resolveExecApprovalRequestAllowedDecisions,
type ExecApprovalChannelRuntime,
type ExecApprovalDecision,
type ExecApprovalRequest,
type ExecApprovalResolved,
} from "openclaw/plugin-sdk/infra-runtime";
import { logError } from "openclaw/plugin-sdk/text-runtime";
import { slackNativeApprovalAdapter } from "../approval-native.js";
import {
isSlackExecApprovalClientEnabled,
normalizeSlackApproverId,
shouldHandleSlackExecApprovalRequest,
} from "../exec-approvals.js";
import { resolveSlackReplyBlocks } from "../reply-blocks.js";
import { sendMessageSlack } from "../send.js";
type SlackBlock = Block | KnownBlock;
type SlackPendingApproval = {
channelId: string;
messageTs: string;
};
type SlackPendingDelivery = {
text: string;
blocks: SlackBlock[];
};
type SlackExecApprovalConfig = NonNullable<
NonNullable<NonNullable<OpenClawConfig["channels"]>["slack"]>["execApprovals"]
>;
type SlackExecApprovalHandlerOpts = {
app: App;
accountId: string;
config: SlackExecApprovalConfig;
gatewayUrl?: string;
cfg: OpenClawConfig;
};
function truncateSlackMrkdwn(text: string, maxChars: number): string {
return text.length <= maxChars ? text : `${text.slice(0, maxChars - 1)}`;
}
function buildSlackCodeBlock(text: string): string {
let fence = "```";
while (text.includes(fence)) {
fence += "`";
}
return `${fence}\n${text}\n${fence}`;
}
function formatSlackApprover(resolvedBy?: string | null): string | null {
const normalized = resolvedBy ? normalizeSlackApproverId(resolvedBy) : undefined;
if (normalized) {
return `<@${normalized}>`;
}
const trimmed = resolvedBy?.trim();
return trimmed ? trimmed : null;
}
function buildSlackApprovalContextLines(request: ExecApprovalRequest): string[] {
const lines: string[] = [];
if (request.request.agentId) {
lines.push(`*Agent:* ${request.request.agentId}`);
}
if (request.request.cwd) {
lines.push(`*CWD:* ${request.request.cwd}`);
}
if (request.request.host) {
lines.push(`*Host:* ${request.request.host}`);
}
return lines;
}
function buildSlackPendingApprovalText(request: ExecApprovalRequest): string {
const { commandText } = resolveExecApprovalCommandDisplay(request.request);
const lines = [
"*Exec approval required*",
"A command needs your approval.",
"",
"*Command*",
buildSlackCodeBlock(commandText),
...buildSlackApprovalContextLines(request),
];
return lines.join("\n");
}
function buildSlackPendingApprovalBlocks(request: ExecApprovalRequest): SlackBlock[] {
const { commandText } = resolveExecApprovalCommandDisplay(request.request);
const metadataLines = buildSlackApprovalContextLines(request);
const interactiveBlocks =
resolveSlackReplyBlocks({
text: "",
interactive: buildApprovalInteractiveReply({
approvalId: request.id,
ask: request.request.ask,
allowedDecisions: resolveExecApprovalRequestAllowedDecisions(request.request),
}),
}) ?? [];
return [
{
type: "section",
text: {
type: "mrkdwn",
text: "*Exec approval required*\nA command needs your approval.",
},
},
{
type: "section",
text: {
type: "mrkdwn",
text: `*Command*\n${buildSlackCodeBlock(truncateSlackMrkdwn(commandText, 2600))}`,
},
},
...(metadataLines.length > 0
? [
{
type: "context",
elements: metadataLines.map((line) => ({
type: "mrkdwn" as const,
text: line,
})),
} satisfies SlackBlock,
]
: []),
...interactiveBlocks,
];
}
function buildSlackResolvedText(params: {
request: ExecApprovalRequest;
decision: ExecApprovalDecision;
resolvedBy?: string | null;
}): string {
const { commandText } = resolveExecApprovalCommandDisplay(params.request.request);
const decisionLabel =
params.decision === "allow-once"
? "Allowed once"
: params.decision === "allow-always"
? "Allowed always"
: "Denied";
const resolvedBy = formatSlackApprover(params.resolvedBy);
const lines = [
`*Exec approval: ${decisionLabel}*`,
resolvedBy ? `Resolved by ${resolvedBy}.` : "Resolved.",
"",
"*Command*",
buildSlackCodeBlock(commandText),
];
return lines.join("\n");
}
function buildSlackResolvedBlocks(params: {
request: ExecApprovalRequest;
decision: ExecApprovalDecision;
resolvedBy?: string | null;
}): SlackBlock[] {
const { commandText } = resolveExecApprovalCommandDisplay(params.request.request);
const decisionLabel =
params.decision === "allow-once"
? "Allowed once"
: params.decision === "allow-always"
? "Allowed always"
: "Denied";
const resolvedBy = formatSlackApprover(params.resolvedBy);
return [
{
type: "section",
text: {
type: "mrkdwn",
text: `*Exec approval: ${decisionLabel}*\n${resolvedBy ? `Resolved by ${resolvedBy}.` : "Resolved."}`,
},
},
{
type: "section",
text: {
type: "mrkdwn",
text: `*Command*\n${buildSlackCodeBlock(truncateSlackMrkdwn(commandText, 2600))}`,
},
},
];
}
function buildSlackExpiredText(request: ExecApprovalRequest): string {
const { commandText } = resolveExecApprovalCommandDisplay(request.request);
return [
"*Exec approval expired*",
"This approval request expired before it was resolved.",
"",
"*Command*",
buildSlackCodeBlock(commandText),
].join("\n");
}
function buildSlackExpiredBlocks(request: ExecApprovalRequest): SlackBlock[] {
const { commandText } = resolveExecApprovalCommandDisplay(request.request);
return [
{
type: "section",
text: {
type: "mrkdwn",
text: "*Exec approval expired*\nThis approval request expired before it was resolved.",
},
},
{
type: "section",
text: {
type: "mrkdwn",
text: `*Command*\n${buildSlackCodeBlock(truncateSlackMrkdwn(commandText, 2600))}`,
},
},
];
}
export class SlackExecApprovalHandler {
private readonly runtime: ExecApprovalChannelRuntime;
private readonly opts: SlackExecApprovalHandlerOpts;
constructor(opts: SlackExecApprovalHandlerOpts) {
this.opts = opts;
this.runtime = createChannelNativeApprovalRuntime<
SlackPendingApproval,
{ to: string; threadTs?: string },
SlackPendingDelivery,
ExecApprovalRequest,
ExecApprovalResolved
>({
label: "slack/exec-approvals",
clientDisplayName: "Slack Exec Approvals",
cfg: opts.cfg,
accountId: opts.accountId,
gatewayUrl: opts.gatewayUrl,
eventKinds: ["exec"],
nativeAdapter: slackNativeApprovalAdapter.native,
isConfigured: () =>
isSlackExecApprovalClientEnabled({
cfg: opts.cfg,
accountId: opts.accountId,
}),
shouldHandle: (request) => this.shouldHandle(request),
buildPendingContent: ({ request }) => ({
text: buildSlackPendingApprovalText(request),
blocks: buildSlackPendingApprovalBlocks(request),
}),
sendOriginNotice: async ({ originTarget }) => {
await sendMessageSlack(originTarget.to, getExecApprovalApproverDmNoticeText(), {
cfg: this.opts.cfg,
accountId: this.opts.accountId,
threadTs: originTarget.threadId != null ? String(originTarget.threadId) : undefined,
client: this.opts.app.client,
});
},
prepareTarget: ({ plannedTarget }) => ({
dedupeKey: `${plannedTarget.target.to}:${plannedTarget.target.threadId == null ? "" : String(plannedTarget.target.threadId)}`,
target: {
to: plannedTarget.target.to,
threadTs:
plannedTarget.target.threadId != null
? String(plannedTarget.target.threadId)
: undefined,
},
}),
deliverTarget: async ({ preparedTarget, pendingContent, request: _request }) => {
const message = await sendMessageSlack(preparedTarget.to, pendingContent.text, {
cfg: this.opts.cfg,
accountId: this.opts.accountId,
threadTs: preparedTarget.threadTs,
blocks: pendingContent.blocks,
client: this.opts.app.client,
});
return {
channelId: message.channelId,
messageTs: message.messageId,
};
},
onOriginNoticeError: ({ error }) => {
logError(`slack exec approvals: failed to send DM redirect notice: ${String(error)}`);
},
onDeliveryError: ({ error, request }) => {
logError(
`slack exec approvals: failed to deliver approval ${request.id}: ${String(error)}`,
);
},
finalizeResolved: async ({ request, resolved, entries }) => {
await this.finalizeResolved(request, resolved, entries);
},
finalizeExpired: async ({ request, entries }) => {
await this.finalizeExpired(request, entries);
},
});
}
shouldHandle(request: ExecApprovalRequest): boolean {
return shouldHandleSlackExecApprovalRequest({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
request,
})
? slackNativeApprovalAdapter.native?.describeDeliveryCapabilities({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
approvalKind: "exec",
request,
}).enabled === true
: false;
}
async start(): Promise<void> {
await this.runtime.start();
}
async stop(): Promise<void> {
await this.runtime.stop();
}
async handleApprovalRequested(request: ExecApprovalRequest): Promise<void> {
await this.runtime.handleRequested(request);
}
async handleApprovalResolved(resolved: ExecApprovalResolved): Promise<void> {
await this.runtime.handleResolved(resolved);
}
async handleApprovalTimeout(approvalId: string): Promise<void> {
await this.runtime.handleExpired(approvalId);
}
private async finalizeResolved(
request: ExecApprovalRequest,
resolved: ExecApprovalResolved,
entries: SlackPendingApproval[],
): Promise<void> {
const text = buildSlackResolvedText({
request,
decision: resolved.decision,
resolvedBy: resolved.resolvedBy,
});
const blocks = buildSlackResolvedBlocks({
request,
decision: resolved.decision,
resolvedBy: resolved.resolvedBy,
});
for (const entry of entries) {
await this.updateMessage({
channelId: entry.channelId,
messageTs: entry.messageTs,
text,
blocks,
});
}
}
private async finalizeExpired(
request: ExecApprovalRequest,
entries: SlackPendingApproval[],
): Promise<void> {
const blocks = buildSlackExpiredBlocks(request);
const text = buildSlackExpiredText(request);
for (const entry of entries) {
await this.updateMessage({
channelId: entry.channelId,
messageTs: entry.messageTs,
text,
blocks,
});
}
}
private async updateMessage(params: {
channelId: string;
messageTs: string;
text: string;
blocks: SlackBlock[];
}): Promise<void> {
try {
await this.opts.app.client.chat.update({
channel: params.channelId,
ts: params.messageTs,
text: params.text,
blocks: params.blocks,
});
} catch (err) {
logError(`slack exec approvals: failed to update message: ${String(err)}`);
}
}
}

View File

@@ -7,6 +7,8 @@ import {
patchAllowlistUsersInConfigEntries,
summarizeMapping,
} from "openclaw/plugin-sdk/allow-from";
import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY } from "openclaw/plugin-sdk/approval-handler-runtime";
import { registerChannelRuntimeContext } from "openclaw/plugin-sdk/channel-runtime-context";
import type { SessionScope } from "openclaw/plugin-sdk/config-runtime";
import { createConnectedChannelStatusPatch } from "openclaw/plugin-sdk/gateway-runtime";
import { DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-history";
@@ -40,7 +42,6 @@ import {
} from "./config.runtime.js";
import { createSlackMonitorContext } from "./context.js";
import { registerSlackMonitorEvents } from "./events.js";
import { SlackExecApprovalHandler } from "./exec-approvals.js";
import { createSlackMessageHandler } from "./message-handler.js";
import {
formatUnknownError,
@@ -443,21 +444,27 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
: undefined;
const handleSlackMessage = createSlackMessageHandler({ ctx, account, trackEvent });
const execApprovalsHandler = isSlackExecApprovalClientEnabled({
cfg,
accountId: account.accountId,
})
? new SlackExecApprovalHandler({
if (
isSlackExecApprovalClientEnabled({
cfg,
accountId: account.accountId,
})
) {
registerChannelRuntimeContext({
channelRuntime: opts.channelRuntime,
channelId: "slack",
accountId: account.accountId,
capability: CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY,
context: {
app,
accountId: account.accountId,
config: slackCfg.execApprovals ?? {},
cfg,
})
: null;
},
abortSignal: opts.abortSignal,
});
}
registerSlackMonitorEvents({ ctx, account, handleSlackMessage, trackEvent });
await registerSlackMonitorSlashCommands({ ctx, account });
await execApprovalsHandler?.start();
if (slackMode === "http" && slackHttpHandler) {
unregisterHttpHandler = registerSlackHttpHandler({
path: slackWebhookPath,
@@ -663,7 +670,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
} finally {
opts.abortSignal?.removeEventListener("abort", stopOnAbort);
unregisterHttpHandler?.();
await execApprovalsHandler?.stop().catch(() => undefined);
await gracefulStop();
}
}

View File

@@ -1,3 +1,4 @@
import type { PluginRuntime } from "openclaw/plugin-sdk/channel-core";
import type { OpenClawConfig, SlackSlashCommandConfig } from "openclaw/plugin-sdk/config-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import type { SlackFile, SlackMessageEvent } from "../types.js";
@@ -9,6 +10,7 @@ export type MonitorSlackOpts = {
mode?: "socket" | "http";
config?: OpenClawConfig;
runtime?: RuntimeEnv;
channelRuntime?: PluginRuntime["channel"];
abortSignal?: AbortSignal;
mediaMaxMb?: number;
slashCommand?: SlackSlashCommandConfig;

View File

@@ -227,7 +227,7 @@ export function createSynologyChatPlugin(): SynologyChatPlugin {
config: {
...synologyChatConfigAdapter,
},
auth: synologyChatApprovalAuth,
approvalCapability: synologyChatApprovalAuth,
messaging: {
normalizeTarget: (target: string) => {
const trimmed = target.trim();

View File

@@ -0,0 +1,188 @@
import type {
ChannelApprovalCapabilityHandlerContext,
PendingApprovalView,
} from "openclaw/plugin-sdk/approval-handler-runtime";
import { createChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approval-native-runtime";
import { buildPluginApprovalPendingReplyPayload } from "openclaw/plugin-sdk/approval-reply-runtime";
import {
buildApprovalInteractiveReplyFromActionDescriptors,
buildExecApprovalPendingReplyPayload,
type ExecApprovalPendingReplyParams,
type ExecApprovalRequest,
type PluginApprovalRequest,
} from "openclaw/plugin-sdk/infra-runtime";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { resolveTelegramInlineButtons } from "./button-types.js";
import {
isTelegramExecApprovalHandlerConfigured,
shouldHandleTelegramExecApprovalRequest,
} from "./exec-approvals.js";
import { editMessageReplyMarkupTelegram, sendMessageTelegram, sendTypingTelegram } from "./send.js";
const log = createSubsystemLogger("telegram/approvals");
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type PendingMessage = {
chatId: string;
messageId: string;
};
type TelegramPendingDelivery = {
text: string;
buttons: ReturnType<typeof resolveTelegramInlineButtons>;
};
export type TelegramExecApprovalHandlerDeps = {
nowMs?: () => number;
sendTyping?: typeof sendTypingTelegram;
sendMessage?: typeof sendMessageTelegram;
editReplyMarkup?: typeof editMessageReplyMarkupTelegram;
};
export type TelegramApprovalHandlerContext = {
token: string;
deps?: TelegramExecApprovalHandlerDeps;
};
function resolveHandlerContext(params: ChannelApprovalCapabilityHandlerContext): {
accountId: string;
context: TelegramApprovalHandlerContext;
} | null {
const context = params.context as TelegramApprovalHandlerContext | undefined;
const accountId = params.accountId?.trim() || "";
if (!context?.token || !accountId) {
return null;
}
return { accountId, context };
}
function buildPendingPayload(params: {
request: ApprovalRequest;
approvalKind: "exec" | "plugin";
nowMs: number;
view: PendingApprovalView;
}): TelegramPendingDelivery {
const payload =
params.approvalKind === "plugin"
? buildPluginApprovalPendingReplyPayload({
request: params.request as PluginApprovalRequest,
nowMs: params.nowMs,
})
: buildExecApprovalPendingReplyPayload({
approvalId: params.request.id,
approvalSlug: params.request.id.slice(0, 8),
approvalCommandId: params.request.id,
command: params.view.approvalKind === "exec" ? params.view.commandText : "",
cwd: params.view.approvalKind === "exec" ? (params.view.cwd ?? undefined) : undefined,
host:
params.view.approvalKind === "exec" && params.view.host === "node" ? "node" : "gateway",
nodeId:
params.view.approvalKind === "exec" ? (params.view.nodeId ?? undefined) : undefined,
allowedDecisions: params.view.actions.map((action) => action.decision),
expiresAtMs: params.request.expiresAtMs,
nowMs: params.nowMs,
} satisfies ExecApprovalPendingReplyParams);
return {
text: payload.text ?? "",
buttons: resolveTelegramInlineButtons({
interactive: buildApprovalInteractiveReplyFromActionDescriptors(params.view.actions),
}),
};
}
export const telegramApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter<
TelegramPendingDelivery,
{ chatId: string; messageThreadId?: number },
PendingMessage,
never
>({
eventKinds: ["exec", "plugin"],
availability: {
isConfigured: (params) => {
const resolved = resolveHandlerContext(params);
return resolved
? isTelegramExecApprovalHandlerConfigured({
cfg: params.cfg,
accountId: resolved.accountId,
})
: false;
},
shouldHandle: (params) => {
const resolved = resolveHandlerContext(params);
return resolved
? shouldHandleTelegramExecApprovalRequest({
cfg: params.cfg,
accountId: resolved.accountId,
request: params.request,
})
: false;
},
},
presentation: {
buildPendingPayload: ({ request, approvalKind, nowMs, view }) =>
buildPendingPayload({ request, approvalKind, nowMs, view }),
buildResolvedResult: () => ({ kind: "clear-actions" }),
buildExpiredResult: () => ({ kind: "clear-actions" }),
},
transport: {
prepareTarget: ({ plannedTarget }) => ({
dedupeKey: buildChannelApprovalNativeTargetKey(plannedTarget.target),
target: {
chatId: plannedTarget.target.to,
messageThreadId:
typeof plannedTarget.target.threadId === "number"
? plannedTarget.target.threadId
: undefined,
},
}),
deliverPending: async ({ cfg, accountId, context, preparedTarget, pendingPayload }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return null;
}
const sendTyping = resolved.context.deps?.sendTyping ?? sendTypingTelegram;
const sendMessage = resolved.context.deps?.sendMessage ?? sendMessageTelegram;
await sendTyping(preparedTarget.chatId, {
cfg,
token: resolved.context.token,
accountId: resolved.accountId,
...(preparedTarget.messageThreadId != null
? { messageThreadId: preparedTarget.messageThreadId }
: {}),
}).catch(() => {});
const result = await sendMessage(preparedTarget.chatId, pendingPayload.text, {
cfg,
token: resolved.context.token,
accountId: resolved.accountId,
buttons: pendingPayload.buttons,
...(preparedTarget.messageThreadId != null
? { messageThreadId: preparedTarget.messageThreadId }
: {}),
});
return {
chatId: result.chatId,
messageId: result.messageId,
};
},
},
interactions: {
clearPendingActions: async ({ cfg, accountId, context, entry }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return;
}
const editReplyMarkup =
resolved.context.deps?.editReplyMarkup ?? editMessageReplyMarkupTelegram;
await editReplyMarkup(entry.chatId, entry.messageId, [], {
cfg,
token: resolved.context.token,
accountId: resolved.accountId,
});
},
},
observe: {
onDeliveryError: ({ error, request }) => {
log.error(`telegram approvals: failed to send request ${request.id}: ${String(error)}`);
},
},
});

View File

@@ -145,4 +145,71 @@ describe("telegram native approval adapter", () => {
threadId: 928,
});
});
it("parses numeric string thread ids from the session store for plugin approvals", async () => {
writeStore({
"agent:main:telegram:group:-1003841603622:topic:928": {
sessionId: "sess",
updatedAt: Date.now(),
deliveryContext: {
channel: "telegram",
to: "-1003841603622",
accountId: "default",
threadId: "928",
},
},
});
const target = await telegramNativeApprovalAdapter.native?.resolveOriginTarget?.({
cfg: {
...buildConfig(),
session: { store: STORE_PATH },
},
accountId: "default",
approvalKind: "plugin",
request: {
id: "plugin:req-2",
request: {
title: "Plugin approval",
description: "Allow access",
sessionKey: "agent:main:telegram:group:-1003841603622:topic:928",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(target).toEqual({
to: "-1003841603622",
threadId: 928,
});
});
it("marks DM-only telegram approvals to notify the origin chat after delivery", () => {
const capabilities = telegramNativeApprovalAdapter.native?.describeDeliveryCapabilities({
cfg: buildConfig(),
accountId: "default",
approvalKind: "exec",
request: {
id: "req-dm-1",
request: {
command: "echo hi",
turnSourceChannel: "telegram",
turnSourceTo: "telegram:-1003841603622:topic:928",
turnSourceAccountId: "default",
turnSourceThreadId: 928,
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(capabilities).toEqual({
enabled: true,
preferredSurface: "approver-dm",
supportsOriginSurface: true,
supportsApproverDmSurface: true,
notifyOriginWhenDmOnly: true,
});
});
});

View File

@@ -2,6 +2,7 @@ import {
createApproverRestrictedNativeApprovalCapability,
splitChannelApprovalCapability,
} from "openclaw/plugin-sdk/approval-delivery-runtime";
import { createLazyChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
import {
createChannelApproverDmTargetResolver,
createChannelNativeOriginTargetResolver,
@@ -22,6 +23,7 @@ import {
resolveTelegramExecApprovalTarget,
shouldHandleTelegramExecApprovalRequest,
} from "./exec-approvals.js";
import { parseTelegramThreadId } from "./outbound-params.js";
import { normalizeTelegramChatId, parseTelegramTarget } from "./targets.js";
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
@@ -39,25 +41,19 @@ function resolveTurnSourceTelegramOriginTarget(
}
const rawThreadId =
request.request.turnSourceThreadId ?? parsedTurnSourceTarget?.messageThreadId ?? undefined;
const threadId =
typeof rawThreadId === "number"
? rawThreadId
: typeof rawThreadId === "string"
? Number.parseInt(rawThreadId, 10)
: undefined;
return {
to: turnSourceTo,
threadId: Number.isFinite(threadId) ? threadId : undefined,
threadId: parseTelegramThreadId(rawThreadId),
};
}
function resolveSessionTelegramOriginTarget(sessionTarget: {
to: string;
threadId?: number | null;
threadId?: string | number | null;
}): TelegramOriginTarget {
return {
to: normalizeTelegramChatId(sessionTarget.to) ?? sessionTarget.to,
threadId: sessionTarget.threadId ?? undefined,
threadId: parseTelegramThreadId(sessionTarget.threadId),
};
}
@@ -118,6 +114,22 @@ const telegramNativeApprovalCapability = createApproverRestrictedNativeApprovalC
normalizeOptionalString(request.request.turnSourceAccountId),
resolveOriginTarget: resolveTelegramOriginTarget,
resolveApproverDmTargets: resolveTelegramApproverDmTargets,
notifyOriginWhenDmOnly: true,
nativeRuntime: createLazyChannelApprovalNativeRuntimeAdapter({
eventKinds: ["exec", "plugin"],
isConfigured: ({ cfg, accountId }) =>
isTelegramExecApprovalClientEnabled({
cfg,
accountId,
}),
shouldHandle: ({ cfg, accountId, request }) =>
shouldHandleTelegramExecApprovalRequest({
cfg,
accountId,
request,
}),
load: async () => (await import("./approval-handler.runtime.js")).telegramApprovalNativeRuntime,
}),
});
const resolveTelegramApproveCommandBehavior: NonNullable<

View File

@@ -899,6 +899,7 @@ export const telegramPlugin = createChatChannelPlugin({
accountId: account.accountId,
config: ctx.cfg,
runtime: ctx.runtime,
channelRuntime: ctx.channelRuntime,
abortSignal: ctx.abortSignal,
useWebhook: Boolean(account.config.webhookUrl),
webhookUrl: account.config.webhookUrl,

View File

@@ -1,6 +1,5 @@
import { resolveApprovalOverGateway } from "openclaw/plugin-sdk/approval-handler-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { isApprovalNotFoundError } from "openclaw/plugin-sdk/error-runtime";
import { withOperatorApprovalsGatewayClient } from "openclaw/plugin-sdk/gateway-runtime";
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/infra-runtime";
export type ResolveTelegramExecApprovalParams = {
@@ -15,33 +14,13 @@ export type ResolveTelegramExecApprovalParams = {
export async function resolveTelegramExecApproval(
params: ResolveTelegramExecApprovalParams,
): Promise<void> {
await withOperatorApprovalsGatewayClient(
{
config: params.cfg,
gatewayUrl: params.gatewayUrl,
clientDisplayName: `Telegram approval (${params.senderId?.trim() || "unknown"})`,
},
async (gatewayClient) => {
const requestApproval = async (
method: "exec.approval.resolve" | "plugin.approval.resolve",
) => {
await gatewayClient.request(method, {
id: params.approvalId,
decision: params.decision,
});
};
if (params.approvalId.startsWith("plugin:")) {
await requestApproval("plugin.approval.resolve");
} else {
try {
await requestApproval("exec.approval.resolve");
} catch (err) {
if (!params.allowPluginFallback || !isApprovalNotFoundError(err)) {
throw err;
}
await requestApproval("plugin.approval.resolve");
}
}
},
);
await resolveApprovalOverGateway({
cfg: params.cfg,
approvalId: params.approvalId,
decision: params.decision,
senderId: params.senderId,
gatewayUrl: params.gatewayUrl,
allowPluginFallback: params.allowPluginFallback,
clientDisplayName: `Telegram approval (${params.senderId?.trim() || "unknown"})`,
});
}

View File

@@ -1,528 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { updateSessionStore } from "../../../src/config/sessions.js";
import { TelegramExecApprovalHandler } from "./exec-approvals-handler.js";
const baseRequest = {
id: "9f1c7d5d-b1fb-46ef-ac45-662723b65bb7",
request: {
command: "npm view diver name version description",
agentId: "main",
sessionKey: "agent:main:telegram:group:-1003841603622:topic:928",
turnSourceChannel: "telegram",
turnSourceTo: "-1003841603622",
turnSourceThreadId: "928",
turnSourceAccountId: "default",
},
createdAtMs: 1000,
expiresAtMs: 61_000,
};
const pluginRequest = {
id: "plugin:9f1c7d5d-b1fb-46ef-ac45-662723b65bb7",
request: {
title: "Plugin Approval Required",
description: "Allow plugin access",
pluginId: "git-tools",
agentId: "main",
sessionKey: "agent:main:telegram:group:-1003841603622:topic:928",
turnSourceChannel: "telegram",
turnSourceTo: "-1003841603622",
turnSourceThreadId: "928",
turnSourceAccountId: "default",
},
createdAtMs: 1000,
expiresAtMs: 61_000,
};
function createHandler(cfg: OpenClawConfig, accountId = "default") {
const normalizedCfg = {
...cfg,
channels: {
...cfg.channels,
telegram: {
...cfg.channels?.telegram,
botToken: cfg.channels?.telegram?.botToken ?? "tg-token",
},
},
} as OpenClawConfig;
const sendTyping = vi.fn().mockResolvedValue({ ok: true });
const sendMessage = vi
.fn()
.mockResolvedValueOnce({ messageId: "m1", chatId: "-1003841603622" })
.mockResolvedValue({ messageId: "m2", chatId: "8460800771" });
const editReplyMarkup = vi.fn().mockResolvedValue({ ok: true });
const handler = new TelegramExecApprovalHandler(
{
token: "tg-token",
accountId,
cfg: normalizedCfg,
},
{
nowMs: () => 1000,
sendTyping,
sendMessage,
editReplyMarkup,
},
);
return { handler, sendTyping, sendMessage, editReplyMarkup };
}
describe("TelegramExecApprovalHandler", () => {
it("sends approval prompts to the originating telegram topic when target=channel", async () => {
const cfg = {
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["8460800771"],
target: "channel",
},
},
},
} as OpenClawConfig;
const { handler, sendTyping, sendMessage } = createHandler(cfg);
await handler.handleRequested(baseRequest);
expect(sendTyping).toHaveBeenCalledWith(
"-1003841603622",
expect.objectContaining({
accountId: "default",
messageThreadId: 928,
}),
);
expect(sendMessage).toHaveBeenCalledWith(
"-1003841603622",
expect.stringContaining("/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once"),
expect.objectContaining({
accountId: "default",
messageThreadId: 928,
buttons: [
[
{
text: "Allow Once",
callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once",
style: "success",
},
{
text: "Allow Always",
callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 always",
style: "primary",
},
{
text: "Deny",
callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 deny",
style: "danger",
},
],
],
}),
);
});
it("hides allow-always actions when ask=always", async () => {
const cfg = {
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["8460800771"],
target: "channel",
},
},
},
} as OpenClawConfig;
const { handler, sendMessage } = createHandler(cfg);
await handler.handleRequested({
...baseRequest,
request: {
...baseRequest.request,
ask: "always",
},
});
expect(sendMessage).toHaveBeenCalledWith(
"-1003841603622",
expect.not.stringContaining("allow-always"),
expect.objectContaining({
buttons: [
[
{
text: "Allow Once",
callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once",
style: "success",
},
{
text: "Deny",
callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 deny",
style: "danger",
},
],
],
}),
);
});
it("falls back to approver DMs when channel routing is unavailable", async () => {
const cfg = {
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["111", "222"],
target: "channel",
},
},
},
} as OpenClawConfig;
const { handler, sendMessage } = createHandler(cfg);
await handler.handleRequested({
...baseRequest,
request: {
...baseRequest.request,
turnSourceChannel: "slack",
turnSourceTo: "U1",
turnSourceAccountId: null,
turnSourceThreadId: null,
},
});
expect(sendMessage).toHaveBeenCalledTimes(2);
expect(sendMessage.mock.calls.map((call) => call[0])).toEqual(["111", "222"]);
});
it("does not send foreign-channel approvals from unbound multi-account telegram configs", async () => {
const cfg = {
channels: {
telegram: {
accounts: {
default: {
execApprovals: {
enabled: true,
approvers: ["111"],
target: "channel",
},
},
secondary: {
execApprovals: {
enabled: true,
approvers: ["222"],
target: "channel",
},
},
},
},
},
} as OpenClawConfig;
const defaultHandler = createHandler(cfg, "default");
const secondaryHandler = createHandler(cfg, "secondary");
const request = {
...baseRequest,
request: {
...baseRequest.request,
sessionKey: "agent:main:missing",
turnSourceChannel: "slack",
turnSourceTo: "U1",
turnSourceAccountId: null,
turnSourceThreadId: null,
},
};
await defaultHandler.handler.handleRequested(request);
await secondaryHandler.handler.handleRequested(request);
expect(defaultHandler.sendMessage).not.toHaveBeenCalled();
expect(secondaryHandler.sendMessage).not.toHaveBeenCalled();
});
it("does not double-send in direct chats when the origin chat is the approver DM", async () => {
const cfg = {
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["8460800771"],
target: "dm",
},
},
},
} as OpenClawConfig;
const { handler, sendMessage } = createHandler(cfg);
await handler.handleRequested({
...baseRequest,
request: {
...baseRequest.request,
sessionKey: "agent:main:telegram:direct:8460800771",
turnSourceTo: "telegram:8460800771",
turnSourceThreadId: undefined,
},
});
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(
"8460800771",
expect.stringContaining("/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once"),
expect.objectContaining({
accountId: "default",
}),
);
});
it("clears buttons from tracked approval messages when resolved", async () => {
const cfg = {
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["8460800771"],
target: "both",
},
},
},
} as OpenClawConfig;
const { handler, editReplyMarkup } = createHandler(cfg);
await handler.handleRequested(baseRequest);
await handler.handleResolved({
id: baseRequest.id,
decision: "allow-once",
resolvedBy: "telegram:8460800771",
ts: 2000,
});
expect(editReplyMarkup).toHaveBeenCalled();
expect(editReplyMarkup).toHaveBeenCalledWith(
"-1003841603622",
"m1",
[],
expect.objectContaining({
accountId: "default",
}),
);
});
it("delivers plugin approvals through the shared native delivery planner", async () => {
const cfg = {
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["8460800771"],
target: "dm",
},
},
},
} as OpenClawConfig;
const { handler, sendMessage } = createHandler(cfg);
await handler.handleRequested(pluginRequest);
const [chatId, text, options] = sendMessage.mock.calls[0] ?? [];
expect(chatId).toBe("8460800771");
expect(text).toContain("Plugin approval required");
expect(options).toEqual(
expect.objectContaining({
accountId: "default",
buttons: expect.arrayContaining([
expect.arrayContaining([
expect.objectContaining({
callback_data: "/approve plugin:9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once",
}),
]),
]),
}),
);
});
it("delivers plugin approvals when the agent only exists in the Telegram session key", async () => {
const cfg = {
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["8460800771"],
agentFilter: ["main"],
target: "dm",
},
},
},
} as OpenClawConfig;
const { handler, sendMessage } = createHandler(cfg);
await handler.handleRequested({
...pluginRequest,
request: {
...pluginRequest.request,
agentId: undefined,
},
});
const [chatId, text] = sendMessage.mock.calls[0] ?? [];
expect(chatId).toBe("8460800771");
expect(text).toContain("Plugin approval required");
});
it("does not deliver plugin approvals for a different Telegram account", async () => {
const cfg = {
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["8460800771"],
target: "dm",
},
accounts: {
secondary: {
execApprovals: {
enabled: true,
approvers: ["999"],
target: "dm",
},
},
},
},
},
} as OpenClawConfig;
const { handler, sendMessage } = createHandler(cfg);
await handler.handleRequested({
...pluginRequest,
request: {
...pluginRequest.request,
turnSourceAccountId: "secondary",
},
});
expect(sendMessage).not.toHaveBeenCalled();
});
it("falls back to the session-bound Telegram account when turn source account is missing", async () => {
const sessionStoreDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-tg-approvals-"));
const storePath = path.join(sessionStoreDir, "sessions.json");
try {
await updateSessionStore(storePath, (store) => {
store[baseRequest.request.sessionKey] = {
sessionId: "session-secondary",
updatedAt: Date.now(),
deliveryContext: {
channel: "telegram",
to: "-1003841603622",
accountId: "secondary",
threadId: 928,
},
};
});
const cfg = {
session: { store: storePath },
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["8460800771"],
target: "channel",
},
accounts: {
secondary: {
execApprovals: {
enabled: true,
approvers: ["999"],
target: "channel",
},
},
},
},
},
} as OpenClawConfig;
const defaultHandler = createHandler(cfg, "default");
const secondaryHandler = createHandler(cfg, "secondary");
const request = {
...baseRequest,
request: {
...baseRequest.request,
turnSourceAccountId: null,
},
};
await defaultHandler.handler.handleRequested(request);
await secondaryHandler.handler.handleRequested(request);
expect(defaultHandler.sendMessage).not.toHaveBeenCalled();
expect(secondaryHandler.sendMessage).toHaveBeenCalledWith(
"-1003841603622",
expect.stringContaining("/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once"),
expect.objectContaining({
accountId: "secondary",
messageThreadId: 928,
}),
);
} finally {
await fs.rm(sessionStoreDir, { recursive: true, force: true });
}
});
it("prefers the explicit Telegram turn-source account over stale session account state", async () => {
const sessionStoreDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-tg-approvals-"));
const storePath = path.join(sessionStoreDir, "sessions.json");
try {
await updateSessionStore(storePath, (store) => {
store[baseRequest.request.sessionKey] = {
sessionId: "session-secondary",
updatedAt: Date.now(),
deliveryContext: {
channel: "telegram",
to: "-1003841603622",
accountId: "secondary",
threadId: 928,
},
};
});
const cfg = {
session: { store: storePath },
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["8460800771"],
target: "channel",
},
accounts: {
secondary: {
execApprovals: {
enabled: true,
approvers: ["999"],
target: "channel",
},
},
},
},
},
} as OpenClawConfig;
const defaultHandler = createHandler(cfg, "default");
const secondaryHandler = createHandler(cfg, "secondary");
await defaultHandler.handler.handleRequested(baseRequest);
await secondaryHandler.handler.handleRequested(baseRequest);
expect(defaultHandler.sendMessage).toHaveBeenCalledWith(
"-1003841603622",
expect.stringContaining("/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once"),
expect.objectContaining({
accountId: "default",
messageThreadId: 928,
}),
);
expect(secondaryHandler.sendMessage).not.toHaveBeenCalled();
} finally {
await fs.rm(sessionStoreDir, { recursive: true, force: true });
}
});
});

View File

@@ -1,216 +0,0 @@
import { buildPluginApprovalPendingReplyPayload } from "openclaw/plugin-sdk/approval-reply-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
createChannelNativeApprovalRuntime,
resolveExecApprovalRequestAllowedDecisions,
type ExecApprovalChannelRuntime,
} from "openclaw/plugin-sdk/infra-runtime";
import { resolveExecApprovalCommandDisplay } from "openclaw/plugin-sdk/infra-runtime";
import {
buildExecApprovalPendingReplyPayload,
type ExecApprovalPendingReplyParams,
} from "openclaw/plugin-sdk/infra-runtime";
import type {
ExecApprovalRequest,
ExecApprovalResolved,
PluginApprovalRequest,
PluginApprovalResolved,
} from "openclaw/plugin-sdk/infra-runtime";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { telegramNativeApprovalAdapter } from "./approval-native.js";
import { resolveTelegramInlineButtons } from "./button-types.js";
import {
isTelegramExecApprovalHandlerConfigured,
shouldHandleTelegramExecApprovalRequest,
} from "./exec-approvals.js";
import { editMessageReplyMarkupTelegram, sendMessageTelegram, sendTypingTelegram } from "./send.js";
const log = createSubsystemLogger("telegram/exec-approvals");
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type ApprovalResolved = ExecApprovalResolved | PluginApprovalResolved;
type PendingMessage = {
chatId: string;
messageId: string;
};
type TelegramPendingDelivery = {
text: string;
buttons: ReturnType<typeof resolveTelegramInlineButtons>;
};
export type TelegramExecApprovalHandlerOpts = {
token: string;
accountId: string;
cfg: OpenClawConfig;
gatewayUrl?: string;
runtime?: RuntimeEnv;
};
export type TelegramExecApprovalHandlerDeps = {
nowMs?: () => number;
sendTyping?: typeof sendTypingTelegram;
sendMessage?: typeof sendMessageTelegram;
editReplyMarkup?: typeof editMessageReplyMarkupTelegram;
};
function isHandlerConfigured(params: { cfg: OpenClawConfig; accountId: string }): boolean {
return isTelegramExecApprovalHandlerConfigured(params);
}
export class TelegramExecApprovalHandler {
private readonly runtime: ExecApprovalChannelRuntime<ApprovalRequest, ApprovalResolved>;
private readonly nowMs: () => number;
private readonly sendTyping: typeof sendTypingTelegram;
private readonly sendMessage: typeof sendMessageTelegram;
private readonly editReplyMarkup: typeof editMessageReplyMarkupTelegram;
constructor(
private readonly opts: TelegramExecApprovalHandlerOpts,
deps: TelegramExecApprovalHandlerDeps = {},
) {
this.nowMs = deps.nowMs ?? Date.now;
this.sendTyping = deps.sendTyping ?? sendTypingTelegram;
this.sendMessage = deps.sendMessage ?? sendMessageTelegram;
this.editReplyMarkup = deps.editReplyMarkup ?? editMessageReplyMarkupTelegram;
this.runtime = createChannelNativeApprovalRuntime<
PendingMessage,
{ chatId: string; messageThreadId?: number },
TelegramPendingDelivery
>({
label: "telegram/exec-approvals",
clientDisplayName: `Telegram Exec Approvals (${this.opts.accountId})`,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
gatewayUrl: this.opts.gatewayUrl,
eventKinds: ["exec", "plugin"],
nowMs: this.nowMs,
nativeAdapter: telegramNativeApprovalAdapter.native,
isConfigured: () =>
isHandlerConfigured({ cfg: this.opts.cfg, accountId: this.opts.accountId }),
shouldHandle: (request) =>
shouldHandleTelegramExecApprovalRequest({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
request,
}),
buildPendingContent: ({ request, approvalKind, nowMs }) => {
const payload =
approvalKind === "plugin"
? buildPluginApprovalPendingReplyPayload({
request: request as PluginApprovalRequest,
nowMs,
})
: buildExecApprovalPendingReplyPayload({
approvalId: request.id,
approvalSlug: request.id.slice(0, 8),
approvalCommandId: request.id,
command: resolveExecApprovalCommandDisplay((request as ExecApprovalRequest).request)
.commandText,
cwd: (request as ExecApprovalRequest).request.cwd ?? undefined,
host: (request as ExecApprovalRequest).request.host === "node" ? "node" : "gateway",
nodeId: (request as ExecApprovalRequest).request.nodeId ?? undefined,
allowedDecisions: resolveExecApprovalRequestAllowedDecisions(
(request as ExecApprovalRequest).request,
),
expiresAtMs: request.expiresAtMs,
nowMs,
} satisfies ExecApprovalPendingReplyParams);
return {
text: payload.text ?? "",
buttons: resolveTelegramInlineButtons({
interactive: payload.interactive,
}),
};
},
prepareTarget: ({ plannedTarget }) => ({
dedupeKey: `${plannedTarget.target.to}:${plannedTarget.target.threadId == null ? "" : String(plannedTarget.target.threadId)}`,
target: {
chatId: plannedTarget.target.to,
messageThreadId:
typeof plannedTarget.target.threadId === "number"
? plannedTarget.target.threadId
: undefined,
},
}),
deliverTarget: async ({ preparedTarget, pendingContent }) => {
await this.sendTyping(preparedTarget.chatId, {
cfg: this.opts.cfg,
token: this.opts.token,
accountId: this.opts.accountId,
...(preparedTarget.messageThreadId != null
? { messageThreadId: preparedTarget.messageThreadId }
: {}),
}).catch(() => {});
const result = await this.sendMessage(preparedTarget.chatId, pendingContent.text, {
cfg: this.opts.cfg,
token: this.opts.token,
accountId: this.opts.accountId,
buttons: pendingContent.buttons,
...(preparedTarget.messageThreadId != null
? { messageThreadId: preparedTarget.messageThreadId }
: {}),
});
return {
chatId: result.chatId,
messageId: result.messageId,
};
},
onDeliveryError: ({ error, request }) => {
log.error(
`telegram exec approvals: failed to send request ${request.id}: ${String(error)}`,
);
},
finalizeResolved: async ({ resolved, entries }) => {
await this.finalizeResolved(resolved, entries);
},
finalizeExpired: async ({ entries }) => {
await this.clearPending(entries);
},
});
}
shouldHandle(request: ApprovalRequest): boolean {
return shouldHandleTelegramExecApprovalRequest({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
request,
});
}
async start(): Promise<void> {
await this.runtime.start();
}
async stop(): Promise<void> {
await this.runtime.stop();
}
async handleRequested(request: ApprovalRequest): Promise<void> {
await this.runtime.handleRequested(request);
}
async handleResolved(resolved: ApprovalResolved): Promise<void> {
await this.runtime.handleResolved(resolved);
}
private async finalizeResolved(
_resolved: ApprovalResolved,
messages: PendingMessage[],
): Promise<void> {
await this.clearPending(messages);
}
private async clearPending(messages: PendingMessage[]): Promise<void> {
await Promise.allSettled(
messages.map(async (message) => {
await this.editReplyMarkup(message.chatId, message.messageId, [], {
cfg: this.opts.cfg,
token: this.opts.token,
accountId: this.opts.accountId,
});
}),
);
}
}

View File

@@ -1,3 +1,2 @@
export { TelegramExecApprovalHandler } from "./exec-approvals-handler.js";
export { TelegramPollingSession } from "./polling-session.js";
export { readTelegramUpdateOffset, writeTelegramUpdateOffset } from "./update-offset-store.js";

View File

@@ -1,2 +1 @@
export { TelegramExecApprovalHandler } from "./exec-approvals-handler.js";
export { startTelegramWebhook } from "./webhook.js";

View File

@@ -1,4 +1,7 @@
import type { RunOptions } from "@grammyjs/runner";
import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY } from "openclaw/plugin-sdk/approval-handler-runtime";
import type { PluginRuntime } from "openclaw/plugin-sdk/channel-core";
import { registerChannelRuntimeContext } from "openclaw/plugin-sdk/channel-runtime-context";
import { resolveAgentMaxConcurrent } from "openclaw/plugin-sdk/config-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
@@ -21,6 +24,7 @@ export type MonitorTelegramOpts = {
accountId?: string;
config?: OpenClawConfig;
runtime?: RuntimeEnv;
channelRuntime?: PluginRuntime["channel"];
abortSignal?: AbortSignal;
useWebhook?: boolean;
webhookPath?: string;
@@ -76,9 +80,6 @@ type TelegramMonitorPollingRuntime = typeof import("./monitor-polling.runtime.js
type TelegramPollingSessionInstance = InstanceType<
TelegramMonitorPollingRuntime["TelegramPollingSession"]
>;
type TelegramExecApprovalHandlerInstance = InstanceType<
TelegramMonitorPollingRuntime["TelegramExecApprovalHandler"]
>;
let telegramMonitorPollingRuntimePromise:
| Promise<typeof import("./monitor-polling.runtime.js")>
@@ -101,7 +102,6 @@ async function loadTelegramMonitorWebhookRuntime() {
export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
const log = opts.runtime?.error ?? console.error;
let pollingSession: TelegramPollingSessionInstance | undefined;
let execApprovalsHandler: TelegramExecApprovalHandlerInstance | undefined;
const unregisterHandler = registerUnhandledRejectionHandler((err) => {
const isNetworkError = isRecoverableTelegramNetworkError(err, { context: "polling" });
@@ -144,16 +144,16 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
opts.proxyFetch ?? (account.config.proxy ? makeProxyFetch(account.config.proxy) : undefined);
if (opts.useWebhook) {
const { TelegramExecApprovalHandler, startTelegramWebhook } =
await loadTelegramMonitorWebhookRuntime();
const { startTelegramWebhook } = await loadTelegramMonitorWebhookRuntime();
if (isTelegramExecApprovalHandlerConfigured({ cfg, accountId: account.accountId })) {
execApprovalsHandler = new TelegramExecApprovalHandler({
token,
registerChannelRuntimeContext({
channelRuntime: opts.channelRuntime,
channelId: "telegram",
accountId: account.accountId,
cfg,
runtime: opts.runtime,
capability: CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY,
context: { token },
abortSignal: opts.abortSignal,
});
await execApprovalsHandler.start();
}
await startTelegramWebhook({
token,
@@ -173,21 +173,18 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
return;
}
const {
TelegramExecApprovalHandler,
TelegramPollingSession,
readTelegramUpdateOffset,
writeTelegramUpdateOffset,
} = await loadTelegramMonitorPollingRuntime();
const { TelegramPollingSession, readTelegramUpdateOffset, writeTelegramUpdateOffset } =
await loadTelegramMonitorPollingRuntime();
if (isTelegramExecApprovalHandlerConfigured({ cfg, accountId: account.accountId })) {
execApprovalsHandler = new TelegramExecApprovalHandler({
token,
registerChannelRuntimeContext({
channelRuntime: opts.channelRuntime,
channelId: "telegram",
accountId: account.accountId,
cfg,
runtime: opts.runtime,
capability: CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY,
context: { token },
abortSignal: opts.abortSignal,
});
await execApprovalsHandler.start();
}
const persistedOffsetRaw = await readTelegramUpdateOffset({
@@ -248,7 +245,6 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
});
await pollingSession.runUntilAbort();
} finally {
await execApprovalsHandler?.stop().catch(() => {});
unregisterHandler();
}
}

View File

@@ -3,6 +3,8 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createQueuedWizardPrompter } from "../../../test/helpers/plugins/setup-wizard.js";
import { checkWhatsAppHeartbeatReady } from "./heartbeat.js";
import { whatsappApprovalAuth } from "./approval-auth.js";
import { whatsappPlugin } from "./channel.js";
import type { OpenClawConfig } from "./runtime-api.js";
import { finalizeWhatsAppSetup } from "./setup-finalize.js";
@@ -119,6 +121,13 @@ describe("whatsapp setup wizard", () => {
hoisted.resolveWhatsAppAuthDir.mockReturnValue({ authDir: "/tmp/openclaw-whatsapp-test" });
});
it("exposes approval auth through approvalCapability only", () => {
expect(whatsappPlugin.approvalCapability).toBe(whatsappApprovalAuth);
expect(typeof whatsappPlugin.auth?.login).toBe("function");
expect("authorizeActorAction" in (whatsappPlugin.auth ?? {})).toBe(false);
expect("getActionAvailabilityState" in (whatsappPlugin.auth ?? {})).toBe(false);
});
it("applies owner allowlist when forceAllowFrom is enabled", async () => {
const harness = createQueuedWizardPrompter({
confirmValues: [false],

View File

@@ -146,8 +146,8 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> =
toolContext,
}),
},
approvalCapability: whatsappApprovalAuth,
auth: {
...whatsappApprovalAuth,
login: async ({ cfg, accountId, runtime, verbose }) => {
const resolvedAccountId =
accountId?.trim() ||

View File

@@ -186,7 +186,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount, ZaloProbeResult> =
},
}),
},
auth: zaloApprovalAuth,
approvalCapability: zaloApprovalAuth,
secrets: {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,

View File

@@ -116,6 +116,14 @@
"types": "./dist/plugin-sdk/approval-delivery-runtime.d.ts",
"default": "./dist/plugin-sdk/approval-delivery-runtime.js"
},
"./plugin-sdk/approval-handler-runtime": {
"types": "./dist/plugin-sdk/approval-handler-runtime.d.ts",
"default": "./dist/plugin-sdk/approval-handler-runtime.js"
},
"./plugin-sdk/channel-runtime-context": {
"types": "./dist/plugin-sdk/channel-runtime-context.d.ts",
"default": "./dist/plugin-sdk/channel-runtime-context.js"
},
"./plugin-sdk/approval-native-runtime": {
"types": "./dist/plugin-sdk/approval-native-runtime.d.ts",
"default": "./dist/plugin-sdk/approval-native-runtime.js"

View File

@@ -18,6 +18,8 @@
"approval-auth-runtime",
"approval-client-runtime",
"approval-delivery-runtime",
"approval-handler-runtime",
"channel-runtime-context",
"approval-native-runtime",
"approval-reply-runtime",
"approval-runtime",

View File

@@ -41,6 +41,7 @@ let sendExecApprovalFollowupResult: typeof import("./bash-tools.exec-host-shared
let maxExecApprovalFollowupFailureLogKeys: typeof import("./bash-tools.exec-host-shared.js").MAX_EXEC_APPROVAL_FOLLOWUP_FAILURE_LOG_KEYS;
let enforceStrictInlineEvalApprovalBoundary: typeof import("./bash-tools.exec-host-shared.js").enforceStrictInlineEvalApprovalBoundary;
let resolveExecHostApprovalContext: typeof import("./bash-tools.exec-host-shared.js").resolveExecHostApprovalContext;
let resolveExecApprovalUnavailableState: typeof import("./bash-tools.exec-host-shared.js").resolveExecApprovalUnavailableState;
let buildExecApprovalPendingToolResult: typeof import("./bash-tools.exec-host-shared.js").buildExecApprovalPendingToolResult;
let sendExecApprovalFollowup: typeof import("./bash-tools.exec-approval-followup.js").sendExecApprovalFollowup;
let logWarn: typeof import("../logger.js").logWarn;
@@ -51,6 +52,7 @@ beforeAll(async () => {
MAX_EXEC_APPROVAL_FOLLOWUP_FAILURE_LOG_KEYS: maxExecApprovalFollowupFailureLogKeys,
enforceStrictInlineEvalApprovalBoundary,
resolveExecHostApprovalContext,
resolveExecApprovalUnavailableState,
buildExecApprovalPendingToolResult,
} = await import("./bash-tools.exec-host-shared.js"));
({ sendExecApprovalFollowup } = await import("./bash-tools.exec-approval-followup.js"));
@@ -124,7 +126,7 @@ describe("sendExecApprovalFollowupResult", () => {
});
describe("resolveExecHostApprovalContext", () => {
it("uses exec-approvals.json agent security even when it is broader than the tool default", () => {
it("does not let exec-approvals.json broaden security beyond the requested policy", () => {
mocks.resolveExecApprovals.mockReturnValue({
defaults: {
security: "allowlist",
@@ -149,7 +151,63 @@ describe("resolveExecHostApprovalContext", () => {
host: "gateway",
});
expect(result.hostSecurity).toBe("full");
expect(result.hostSecurity).toBe("allowlist");
});
it("does not let host ask=off suppress a stricter requested ask mode", () => {
mocks.resolveExecApprovals.mockReturnValue({
defaults: {
security: "full",
ask: "off",
askFallback: "full",
autoAllowSkills: false,
},
agent: {
security: "full",
ask: "off",
askFallback: "full",
autoAllowSkills: false,
},
allowlist: [],
file: { version: 1, agents: {} },
});
const result = resolveExecHostApprovalContext({
agentId: "agent-main",
security: "full",
ask: "always",
host: "gateway",
});
expect(result.hostAsk).toBe("always");
});
it("clamps askFallback to the effective host security", () => {
mocks.resolveExecApprovals.mockReturnValue({
defaults: {
security: "full",
ask: "always",
askFallback: "full",
autoAllowSkills: false,
},
agent: {
security: "full",
ask: "always",
askFallback: "full",
autoAllowSkills: false,
},
allowlist: [],
file: { version: 1, agents: {} },
});
const result = resolveExecHostApprovalContext({
agentId: "agent-main",
security: "allowlist",
ask: "always",
host: "gateway",
});
expect(result.askFallback).toBe("allowlist");
});
});
@@ -184,6 +242,19 @@ describe("enforceStrictInlineEvalApprovalBoundary", () => {
});
describe("buildExecApprovalPendingToolResult", () => {
it("does not infer approver DM delivery from unavailable approval state", () => {
expect(
resolveExecApprovalUnavailableState({
turnSourceChannel: "telegram",
turnSourceAccountId: "default",
preResolvedDecision: null,
}),
).toMatchObject({
sentApproverDms: false,
unavailableReason: "no-approval-route",
});
});
it("keeps a local /approve prompt when the initiating Discord surface is disabled", () => {
const result = buildExecApprovalPendingToolResult({
host: "gateway",

View File

@@ -1,14 +1,13 @@
import crypto from "node:crypto";
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { loadConfig } from "../config/config.js";
import { formatErrorMessage } from "../infra/errors.js";
import { buildExecApprovalUnavailableReplyPayload } from "../infra/exec-approval-reply.js";
import {
hasConfiguredExecApprovalDmRoute,
type ExecApprovalInitiatingSurfaceState,
resolveExecApprovalInitiatingSurfaceState,
} from "../infra/exec-approval-surface.js";
import {
minSecurity,
maxAsk,
resolveExecApprovalAllowedDecisions,
resolveExecApprovals,
@@ -195,17 +194,11 @@ export function resolveExecHostApprovalContext(params: {
security: params.security,
ask: params.ask,
});
// exec-approvals.json is the authoritative security policy and must be able to grant
// a less-restrictive level (e.g. "full") even when tool/runtime defaults are stricter
// (e.g. "allowlist"). This matches node-host behavior and mirrors the ask=off special
// case: exec-approvals.json can suppress prompts AND grant broader execution rights.
// When exec-approvals.json has no explicit agent or defaults entry, approvals.agent.security
// falls back to params.security, so this is backward-compatible.
const hostSecurity = approvals.agent.security;
// An explicit ask=off policy in exec-approvals.json must be able to suppress
// prompts even when tool/runtime defaults are stricter (for example on-miss).
const hostAsk = approvals.agent.ask === "off" ? "off" : maxAsk(params.ask, approvals.agent.ask);
const askFallback = approvals.agent.askFallback;
// Session/config tool policy is the caller's requested contract. The host file
// may tighten that contract, but it must not silently broaden it.
const hostSecurity = minSecurity(params.security, approvals.agent.security);
const hostAsk = maxAsk(params.ask, approvals.agent.ask);
const askFallback = minSecurity(hostSecurity, approvals.agent.askFallback);
if (hostSecurity === "deny") {
throw new Error(`exec denied: host=${params.host} security=deny`);
}
@@ -241,9 +234,9 @@ export function resolveExecApprovalUnavailableState(params: {
channel: params.turnSourceChannel,
accountId: params.turnSourceAccountId,
});
const sentApproverDms =
(initiatingSurface.kind === "disabled" || initiatingSurface.kind === "unsupported") &&
hasConfiguredExecApprovalDmRoute(loadConfig());
// Native approval runtimes emit routed-elsewhere notices after actual delivery.
// Avoid claiming approver DMs were sent from config-only guesses here.
const sentApproverDms = false;
const unavailableReason =
params.preResolvedDecision === null
? "no-approval-route"

View File

@@ -57,6 +57,7 @@ function createLifecycleContext(params: {
pendingMessagingTargets: new Map(),
successfulCronAdds: 0,
pendingMessagingMediaUrls: new Map(),
deterministicApprovalPromptPending: false,
deterministicApprovalPromptSent: false,
} as never,
log: {

View File

@@ -153,6 +153,7 @@ describe("handleMessageUpdate", () => {
onPartialReply,
},
state: {
deterministicApprovalPromptPending: false,
deterministicApprovalPromptSent: false,
reasoningStreamOpen: false,
streamReasoning: false,
@@ -211,6 +212,7 @@ describe("handleMessageUpdate", () => {
onPartialReply,
},
state: {
deterministicApprovalPromptPending: false,
deterministicApprovalPromptSent: false,
reasoningStreamOpen: false,
streamReasoning: false,
@@ -263,6 +265,7 @@ describe("handleMessageUpdate", () => {
onAgentEvent,
},
state: {
deterministicApprovalPromptPending: false,
deterministicApprovalPromptSent: false,
reasoningStreamOpen: false,
streamReasoning: false,
@@ -361,6 +364,7 @@ describe("handleMessageUpdate", () => {
session: { id: "session-1" },
},
state: {
deterministicApprovalPromptPending: false,
deterministicApprovalPromptSent: false,
reasoningStreamOpen: false,
streamReasoning: false,
@@ -413,6 +417,7 @@ describe("handleMessageEnd", () => {
assistantTexts: [],
assistantTextBaseline: 0,
emittedAssistantUpdate: false,
deterministicApprovalPromptPending: false,
deterministicApprovalPromptSent: false,
reasoningStreamOpen: false,
includeReasoning: false,
@@ -470,6 +475,7 @@ describe("handleMessageEnd", () => {
assistantTexts: [],
assistantTextBaseline: 0,
emittedAssistantUpdate: false,
deterministicApprovalPromptPending: false,
deterministicApprovalPromptSent: false,
reasoningStreamOpen: false,
includeReasoning: false,
@@ -531,6 +537,7 @@ describe("handleMessageEnd", () => {
onBlockReply,
},
state: {
deterministicApprovalPromptPending: false,
deterministicApprovalPromptSent: false,
messagingToolSentTexts: [],
messagingToolSentTextsNormalized: [],
@@ -592,6 +599,7 @@ describe("handleMessageEnd", () => {
onBlockReply,
},
state: {
deterministicApprovalPromptPending: false,
deterministicApprovalPromptSent: false,
messagingToolSentTexts: [],
messagingToolSentTextsNormalized: [],
@@ -651,6 +659,7 @@ describe("handleMessageEnd", () => {
onAgentEvent,
},
state: {
deterministicApprovalPromptPending: false,
deterministicApprovalPromptSent: false,
messagingToolSentTexts: [],
messagingToolSentTextsNormalized: [],

View File

@@ -204,9 +204,11 @@ export function handleMessageUpdate(
ctx.noteLastAssistant(msg);
const suppressVisibleAssistantOutput = shouldSuppressAssistantVisibleOutput(msg);
if (ctx.state.deterministicApprovalPromptSent) {
if (suppressVisibleAssistantOutput) {
return;
}
const suppressDeterministicApprovalOutput =
ctx.state.deterministicApprovalPromptPending || ctx.state.deterministicApprovalPromptSent;
const assistantEvent = evt.assistantMessageEvent;
const assistantRecord =
@@ -262,10 +264,6 @@ export function handleMessageUpdate(
content,
});
if (suppressVisibleAssistantOutput) {
return;
}
let chunk = "";
if (evtType === "text_delta") {
chunk = delta;
@@ -379,7 +377,7 @@ export function handleMessageUpdate(
ctx.state.lastStreamedAssistant = next;
ctx.state.lastStreamedAssistantCleaned = cleanedText;
if (ctx.params.silentExpected) {
if (ctx.params.silentExpected || suppressDeterministicApprovalOutput) {
shouldEmit = false;
}
@@ -408,6 +406,7 @@ export function handleMessageUpdate(
if (
!ctx.params.silentExpected &&
!suppressDeterministicApprovalOutput &&
ctx.params.onBlockReply &&
ctx.blockChunking &&
ctx.state.blockReplyBreak === "text_end"
@@ -417,6 +416,7 @@ export function handleMessageUpdate(
if (
!ctx.params.silentExpected &&
!suppressDeterministicApprovalOutput &&
evtType === "text_end" &&
ctx.state.blockReplyBreak === "text_end"
) {
@@ -440,9 +440,11 @@ export function handleMessageEnd(
const assistantMessage = msg;
const suppressVisibleAssistantOutput = shouldSuppressAssistantVisibleOutput(assistantMessage);
const suppressDeterministicApprovalOutput =
ctx.state.deterministicApprovalPromptPending || ctx.state.deterministicApprovalPromptSent;
ctx.noteLastAssistant(assistantMessage);
ctx.recordAssistantUsage((assistantMessage as { usage?: unknown }).usage);
if (ctx.state.deterministicApprovalPromptSent) {
if (suppressVisibleAssistantOutput) {
return;
}
promoteThinkingTagsToBlocks(assistantMessage);
@@ -484,12 +486,6 @@ export function handleMessageEnd(
ctx.state.reasoningStreamOpen = false;
};
if (suppressVisibleAssistantOutput) {
emitReasoningEnd(ctx);
finalizeMessageEnd();
return;
}
const previousStreamedText = ctx.state.lastStreamedAssistantCleaned ?? "";
const shouldReplaceFinalStream = Boolean(
previousStreamedText && cleanedText && !cleanedText.startsWith(previousStreamedText),
@@ -503,6 +499,7 @@ export function handleMessageEnd(
if (
!ctx.params.silentExpected &&
!suppressDeterministicApprovalOutput &&
(cleanedText || hasMedia) &&
(!ctx.state.emittedAssistantUpdate ||
shouldReplaceFinalStream ||
@@ -542,6 +539,7 @@ export function handleMessageEnd(
const onBlockReply = ctx.params.onBlockReply;
const shouldEmitReasoning = Boolean(
!ctx.params.silentExpected &&
!suppressDeterministicApprovalOutput &&
ctx.state.includeReasoning &&
formattedReasoning &&
onBlockReply &&
@@ -594,6 +592,7 @@ export function handleMessageEnd(
if (
!ctx.params.silentExpected &&
!suppressDeterministicApprovalOutput &&
text &&
onBlockReply &&
(ctx.state.blockReplyBreak === "message_end" ||

View File

@@ -35,6 +35,7 @@ function createMockContext(overrides?: {
messagingToolSentTextsNormalized: [],
messagingToolSentMediaUrls: [],
messagingToolSentTargets: [],
deterministicApprovalPromptPending: false,
deterministicApprovalPromptSent: false,
},
log: { debug: vi.fn(), warn: vi.fn() },

View File

@@ -47,6 +47,7 @@ function createTestContext(): {
pendingMessagingMediaUrls: new Map<string, string[]>(),
pendingToolMediaUrls: [],
pendingToolAudioAsVoice: false,
deterministicApprovalPromptPending: false,
messagingToolSentTexts: [],
messagingToolSentTextsNormalized: [],
messagingToolSentMediaUrls: [],

View File

@@ -429,6 +429,7 @@ async function emitToolResultOutput(params: {
if (!ctx.params.onToolResult) {
return;
}
ctx.state.deterministicApprovalPromptPending = true;
try {
await ctx.params.onToolResult(
buildExecApprovalPendingReplyPayload({
@@ -445,7 +446,9 @@ async function emitToolResultOutput(params: {
);
ctx.state.deterministicApprovalPromptSent = true;
} catch {
// ignore delivery failures
ctx.state.deterministicApprovalPromptSent = false;
} finally {
ctx.state.deterministicApprovalPromptPending = false;
}
return;
}
@@ -455,6 +458,7 @@ async function emitToolResultOutput(params: {
if (!ctx.params.onToolResult) {
return;
}
ctx.state.deterministicApprovalPromptPending = true;
try {
await ctx.params.onToolResult?.(
buildExecApprovalUnavailableReplyPayload({
@@ -468,7 +472,9 @@ async function emitToolResultOutput(params: {
);
ctx.state.deterministicApprovalPromptSent = true;
} catch {
// ignore delivery failures
ctx.state.deterministicApprovalPromptSent = false;
} finally {
ctx.state.deterministicApprovalPromptPending = false;
}
return;
}

View File

@@ -75,6 +75,7 @@ export type EmbeddedPiSubscribeState = {
pendingMessagingMediaUrls: Map<string, string[]>;
pendingToolMediaUrls: string[];
pendingToolAudioAsVoice: boolean;
deterministicApprovalPromptPending: boolean;
deterministicApprovalPromptSent: boolean;
lastAssistant?: AgentMessage;
};
@@ -157,6 +158,7 @@ export type ToolHandlerState = Pick<
| "pendingMessagingMediaUrls"
| "pendingToolMediaUrls"
| "pendingToolAudioAsVoice"
| "deterministicApprovalPromptPending"
| "messagingToolSentTexts"
| "messagingToolSentTextsNormalized"
| "messagingToolSentMediaUrls"

View File

@@ -154,7 +154,7 @@ describe("subscribeEmbeddedPiSession", () => {
},
);
it("does not let tool_execution_end delivery stall later assistant streaming", async () => {
it("suppresses assistant streaming while deterministic exec approval delivery is pending", async () => {
let resolveToolResult: (() => void) | undefined;
const onToolResult = vi.fn(
() =>
@@ -200,13 +200,13 @@ describe("subscribeEmbeddedPiSession", () => {
await vi.waitFor(() => {
expect(onToolResult).toHaveBeenCalledTimes(1);
expect(onPartialReply).toHaveBeenCalledWith(
expect.objectContaining({ text: "After tool", delta: "After tool" }),
);
});
expect(onPartialReply).not.toHaveBeenCalled();
expect(resolveToolResult).toBeTypeOf("function");
resolveToolResult?.();
await Promise.resolve();
expect(onPartialReply).not.toHaveBeenCalled();
});
it("attaches media from internal completion events even when assistant omits MEDIA lines", async () => {

View File

@@ -113,6 +113,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
pendingMessagingMediaUrls: new Map(),
pendingToolMediaUrls: initialPendingToolMediaUrls,
pendingToolAudioAsVoice: false,
deterministicApprovalPromptPending: false,
deterministicApprovalPromptSent: false,
};
const usageTotals = {
@@ -687,6 +688,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
state.pendingMessagingMediaUrls.clear();
state.pendingToolMediaUrls = [];
state.pendingToolAudioAsVoice = false;
state.deterministicApprovalPromptPending = false;
state.deterministicApprovalPromptSent = false;
resetAssistantMessageState(0);
};

View File

@@ -12,6 +12,7 @@ export function createBaseToolHandlerState() {
pendingMessagingMediaUrls: new Map<string, string[]>(),
pendingToolMediaUrls: [] as string[],
pendingToolAudioAsVoice: false,
deterministicApprovalPromptPending: false,
messagingToolSentTexts: [] as string[],
messagingToolSentTextsNormalized: [] as string[],
messagingToolSentMediaUrls: [] as string[],

View File

@@ -159,4 +159,55 @@ describe("directive behavior exec agent defaults", () => {
});
});
});
it("replaces a prior deny override with newer exec settings on later turns", async () => {
await withTempHome(async (home) => {
runEmbeddedPiAgentMock.mockResolvedValue(makeEmbeddedTextResult("done"));
await getReplyFromConfig(
{
Body: "/exec host=gateway security=deny ask=off",
From: "+1004",
To: "+2000",
CommandAuthorized: true,
},
{},
makeAgentExecConfig(home),
);
await getReplyFromConfig(
{
Body: "/exec host=gateway security=full ask=always",
From: "+1004",
To: "+2000",
CommandAuthorized: true,
},
{},
makeAgentExecConfig(home),
);
runEmbeddedPiAgentMock.mockClear();
await getReplyFromConfig(
{
Body: "run a command",
From: "+1004",
To: "+2000",
Provider: "whatsapp",
SenderE164: "+1004",
},
{},
makeAgentExecConfig(home),
);
expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce();
const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0];
expect(call?.execOverrides).toEqual({
host: "gateway",
security: "full",
ask: "always",
node: "worker-alpha",
});
});
});
});

View File

@@ -79,7 +79,11 @@ const discordApproveTestPlugin: ChannelPlugin = {
nativeCommands: true,
},
}),
auth: discordNativeApprovalAdapterForTests.auth,
approvalCapability: {
authorizeActorAction: discordNativeApprovalAdapterForTests.auth.authorizeActorAction,
getActionAvailabilityState:
discordNativeApprovalAdapterForTests.auth.getActionAvailabilityState,
},
};
const slackApproveTestPlugin: ChannelPlugin = {
@@ -108,7 +112,7 @@ const signalApproveTestPlugin: ChannelPlugin = {
nativeCommands: true,
},
}),
auth: createResolvedApproverActionAuthAdapter({
approvalCapability: createResolvedApproverActionAuthAdapter({
channelLabel: "Signal",
resolveApprovers: ({ cfg, accountId }) => {
const signal = accountId ? cfg.channels?.signal?.accounts?.[accountId] : cfg.channels?.signal;
@@ -308,8 +312,9 @@ const telegramApproveTestPlugin: ChannelPlugin = {
DEFAULT_ACCOUNT_ID,
},
}),
auth: telegramNativeApprovalAdapter.auth,
approvalCapability: {
authorizeActorAction: telegramNativeApprovalAdapter.auth.authorizeActorAction,
getActionAvailabilityState: telegramNativeApprovalAdapter.auth.getActionAvailabilityState,
resolveApproveCommandBehavior: ({ cfg, accountId, senderId, approvalKind }) => {
if (approvalKind !== "exec") {
return undefined;
@@ -608,7 +613,7 @@ describe("handleApproveCommand", () => {
pluginId: "slack",
plugin: {
...createChannelTestPluginBase({ id: "slack", label: "Slack" }),
auth: {
approvalCapability: {
authorizeActorAction: () => ({ authorized: true }),
getActionAvailabilityState: () => ({ kind: "disabled" }),
},
@@ -798,7 +803,7 @@ describe("handleApproveCommand", () => {
pluginId: "matrix",
plugin: {
...createChannelTestPluginBase({ id: "matrix", label: "Matrix" }),
auth: {
approvalCapability: {
authorizeActorAction: ({ approvalKind }: { approvalKind: "exec" | "plugin" }) =>
approvalKind === "plugin"
? { authorized: true }

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { buildExecOverridePromptHint } from "./get-reply-run.js";
describe("buildExecOverridePromptHint", () => {
it("returns undefined when exec state is fully inherited and elevated is off", () => {
expect(
buildExecOverridePromptHint({
elevatedLevel: "off",
}),
).toBeUndefined();
});
it("includes current exec defaults and warns against stale denial assumptions", () => {
const result = buildExecOverridePromptHint({
execOverrides: {
host: "gateway",
security: "full",
ask: "always",
node: "worker-1",
},
elevatedLevel: "off",
});
expect(result).toContain(
"Current session exec defaults: host=gateway security=full ask=always node=worker-1.",
);
expect(result).toContain("Current elevated level: off.");
expect(result).toContain("Do not assume a prior denial still applies");
});
it("still reports elevated state when exec overrides are inherited", () => {
const result = buildExecOverridePromptHint({
elevatedLevel: "full",
});
expect(result).toContain(
"Current session exec defaults: inherited from configured agent/global defaults.",
);
expect(result).toContain("Current elevated level: full.");
});
});

View File

@@ -50,6 +50,33 @@ import type { TypingController } from "./typing.js";
type AgentDefaults = NonNullable<OpenClawConfig["agents"]>["defaults"];
type ExecOverrides = Pick<ExecToolDefaults, "host" | "security" | "ask" | "node">;
export function buildExecOverridePromptHint(params: {
execOverrides?: ExecOverrides;
elevatedLevel: ElevatedLevel;
}): string | undefined {
const exec = params.execOverrides;
if (!exec && params.elevatedLevel === "off") {
return undefined;
}
const parts = [
exec?.host ? `host=${exec.host}` : undefined,
exec?.security ? `security=${exec.security}` : undefined,
exec?.ask ? `ask=${exec.ask}` : undefined,
exec?.node ? `node=${exec.node}` : undefined,
].filter(Boolean);
const execLine =
parts.length > 0
? `Current session exec defaults: ${parts.join(" ")}.`
: "Current session exec defaults: inherited from configured agent/global defaults.";
const elevatedLine = `Current elevated level: ${params.elevatedLevel}.`;
return [
"## Current Exec Session State",
execLine,
elevatedLine,
"If the user asks to run a command, use the current exec state above. Do not assume a prior denial still applies after `/exec` or `/elevated` changed.",
].join("\n");
}
let piEmbeddedRuntimePromise: Promise<typeof import("../../agents/pi-embedded.runtime.js")> | null =
null;
let agentRunnerRuntimePromise: Promise<typeof import("./agent-runner.runtime.js")> | null = null;
@@ -231,6 +258,10 @@ export async function runPreparedReply(
groupChatContext,
groupIntro,
groupSystemPrompt,
buildExecOverridePromptHint({
execOverrides,
elevatedLevel: resolvedElevatedLevel,
}),
].filter(Boolean);
const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
// Use CommandBody/RawBody for bare reset detection (clean message without structural context).

View File

@@ -1,55 +1,49 @@
import { describe, expect, it, vi } from "vitest";
import { resolveChannelApprovalAdapter, resolveChannelApprovalCapability } from "./approvals.js";
describe("resolveChannelApprovalCapability", () => {
it("falls back to legacy approval fields when approvalCapability is absent", () => {
const authorizeActorAction = vi.fn();
const getActionAvailabilityState = vi.fn();
const delivery = { hasConfiguredDmRoute: vi.fn() };
const describeExecApprovalSetup = vi.fn();
function createNativeRuntimeStub() {
return {
availability: {
isConfigured: vi.fn(),
shouldHandle: vi.fn(),
},
presentation: {
buildPendingPayload: vi.fn(),
buildResolvedResult: vi.fn(),
buildExpiredResult: vi.fn(),
},
transport: {
prepareTarget: vi.fn(),
deliverPending: vi.fn(),
},
};
}
expect(
resolveChannelApprovalCapability({
auth: {
authorizeActorAction,
getActionAvailabilityState,
},
approvals: {
describeExecApprovalSetup,
delivery,
},
}),
).toEqual({
authorizeActorAction,
getActionAvailabilityState,
describeExecApprovalSetup,
delivery,
render: undefined,
native: undefined,
});
describe("resolveChannelApprovalCapability", () => {
it("returns undefined when approvalCapability is absent", () => {
expect(resolveChannelApprovalCapability({})).toBeUndefined();
});
it("merges partial approvalCapability fields with legacy approval wiring", () => {
it("returns approvalCapability as the canonical approval contract", () => {
const capabilityAuth = vi.fn();
const legacyAvailability = vi.fn();
const legacyDelivery = { hasConfiguredDmRoute: vi.fn() };
const capabilityAvailability = vi.fn();
const capabilityNativeRuntime = createNativeRuntimeStub();
const delivery = { hasConfiguredDmRoute: vi.fn() };
expect(
resolveChannelApprovalCapability({
approvalCapability: {
authorizeActorAction: capabilityAuth,
},
auth: {
getActionAvailabilityState: legacyAvailability,
},
approvals: {
delivery: legacyDelivery,
getActionAvailabilityState: capabilityAvailability,
delivery,
nativeRuntime: capabilityNativeRuntime,
},
}),
).toEqual({
authorizeActorAction: capabilityAuth,
getActionAvailabilityState: legacyAvailability,
delivery: legacyDelivery,
getActionAvailabilityState: capabilityAvailability,
delivery,
nativeRuntime: capabilityNativeRuntime,
render: undefined,
native: undefined,
});
@@ -57,23 +51,24 @@ describe("resolveChannelApprovalCapability", () => {
});
describe("resolveChannelApprovalAdapter", () => {
it("preserves legacy delivery surfaces when approvalCapability only defines auth", () => {
it("returns only delivery/runtime surfaces from approvalCapability", () => {
const delivery = { hasConfiguredDmRoute: vi.fn() };
const nativeRuntime = createNativeRuntimeStub();
const describeExecApprovalSetup = vi.fn();
expect(
resolveChannelApprovalAdapter({
approvalCapability: {
authorizeActorAction: vi.fn(),
},
approvals: {
describeExecApprovalSetup,
delivery,
nativeRuntime,
authorizeActorAction: vi.fn(),
},
}),
).toEqual({
describeExecApprovalSetup,
delivery,
nativeRuntime,
render: undefined,
native: undefined,
});

View File

@@ -1,72 +1,30 @@
import type { ChannelApprovalAdapter, ChannelApprovalCapability, ChannelPlugin } from "./types.js";
function buildApprovalCapabilityFromLegacyPlugin(
plugin?: Pick<ChannelPlugin, "auth" | "approvals"> | null,
): ChannelApprovalCapability | undefined {
const authorizeActorAction = plugin?.auth?.authorizeActorAction;
const getActionAvailabilityState = plugin?.auth?.getActionAvailabilityState;
const resolveApproveCommandBehavior = plugin?.auth?.resolveApproveCommandBehavior;
const approvals = plugin?.approvals;
if (
!authorizeActorAction &&
!getActionAvailabilityState &&
!resolveApproveCommandBehavior &&
!approvals?.describeExecApprovalSetup &&
!approvals?.delivery &&
!approvals?.render &&
!approvals?.native
) {
return undefined;
}
return {
authorizeActorAction,
getActionAvailabilityState,
resolveApproveCommandBehavior,
describeExecApprovalSetup: approvals?.describeExecApprovalSetup,
delivery: approvals?.delivery,
render: approvals?.render,
native: approvals?.native,
};
}
export function resolveChannelApprovalCapability(
plugin?: Pick<ChannelPlugin, "approvalCapability" | "auth" | "approvals"> | null,
plugin?: Pick<ChannelPlugin, "approvalCapability"> | null,
): ChannelApprovalCapability | undefined {
const capability = plugin?.approvalCapability;
const legacyCapability = buildApprovalCapabilityFromLegacyPlugin(plugin);
if (!capability) {
return legacyCapability;
}
if (!legacyCapability) {
return capability;
}
return {
authorizeActorAction: capability.authorizeActorAction ?? legacyCapability.authorizeActorAction,
getActionAvailabilityState:
capability.getActionAvailabilityState ?? legacyCapability.getActionAvailabilityState,
resolveApproveCommandBehavior:
capability.resolveApproveCommandBehavior ?? legacyCapability.resolveApproveCommandBehavior,
describeExecApprovalSetup:
capability.describeExecApprovalSetup ?? legacyCapability.describeExecApprovalSetup,
delivery: capability.delivery ?? legacyCapability.delivery,
render: capability.render ?? legacyCapability.render,
native: capability.native ?? legacyCapability.native,
};
return plugin?.approvalCapability;
}
export function resolveChannelApprovalAdapter(
plugin?: Pick<ChannelPlugin, "approvalCapability" | "auth" | "approvals"> | null,
plugin?: Pick<ChannelPlugin, "approvalCapability"> | null,
): ChannelApprovalAdapter | undefined {
const capability = resolveChannelApprovalCapability(plugin);
if (!capability) {
return undefined;
}
if (!capability.delivery && !capability.render && !capability.native) {
if (
!capability.delivery &&
!capability.nativeRuntime &&
!capability.render &&
!capability.native
) {
return undefined;
}
return {
describeExecApprovalSetup: capability.describeExecApprovalSetup,
delivery: capability.delivery,
nativeRuntime: capability.nativeRuntime,
render: capability.render,
native: capability.native,
};

View File

@@ -3,6 +3,7 @@ import type { ConfiguredBindingRule } from "../../config/bindings.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { LegacyConfigRule } from "../../config/legacy.shared.js";
import type { GroupToolPolicyConfig } from "../../config/types.tools.js";
import type { ChannelApprovalNativeRuntimeAdapter } from "../../infra/approval-handler-runtime.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";
@@ -391,6 +392,8 @@ export type ChannelGatewayContext<ResolvedAccount = unknown> = {
* - Built-in channels (slack, discord, etc.) typically don't use this field
* because they can directly import internal modules
* - External plugins should check for undefined before using
* - When provided, this must be a full `createPluginRuntime().channel` surface;
* partial stubs are not supported
*
* @since Plugin SDK 2026.2.19
* @see {@link https://docs.openclaw.ai/plugins/developing-plugins | Plugin SDK documentation}
@@ -458,22 +461,6 @@ export type ChannelAuthAdapter = {
verbose?: boolean;
channelInput?: string | null;
}) => Promise<void>;
authorizeActorAction?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
senderId?: string | null;
action: "approve";
approvalKind: "exec" | "plugin";
}) => {
authorized: boolean;
reason?: string;
};
getActionAvailabilityState?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
action: "approve";
}) => ChannelActionAvailabilityState;
resolveApproveCommandBehavior?: ChannelApprovalCapability["resolveApproveCommandBehavior"];
};
export type ChannelHeartbeatAdapter = {
@@ -755,6 +742,7 @@ export type ChannelApprovalRenderAdapter = {
export type ChannelApprovalAdapter = {
delivery?: ChannelApprovalDeliveryAdapter;
nativeRuntime?: ChannelApprovalNativeRuntimeAdapter;
render?: ChannelApprovalRenderAdapter;
native?: ChannelApprovalNativeAdapter;
describeExecApprovalSetup?: (params: {
@@ -765,8 +753,28 @@ export type ChannelApprovalAdapter = {
};
export type ChannelApprovalCapability = ChannelApprovalAdapter & {
authorizeActorAction?: ChannelAuthAdapter["authorizeActorAction"];
getActionAvailabilityState?: ChannelAuthAdapter["getActionAvailabilityState"];
authorizeActorAction?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
senderId?: string | null;
action: "approve";
approvalKind: "exec" | "plugin";
}) => {
authorized: boolean;
reason?: string;
};
getActionAvailabilityState?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
action: "approve";
approvalKind?: ChannelApprovalKind;
}) => ChannelActionAvailabilityState;
/** Exec-native client availability for the initiating surface; distinct from same-chat auth. */
getExecInitiatingSurfaceState?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
action: "approve";
}) => ChannelActionAvailabilityState;
resolveApproveCommandBehavior?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;

View File

@@ -1,7 +1,6 @@
import type { ChannelSetupWizardAdapter } from "./setup-wizard-types.js";
import type { ChannelSetupWizard } from "./setup-wizard.js";
import type {
ChannelApprovalAdapter,
ChannelApprovalCapability,
ChannelAuthAdapter,
ChannelCommandAdapter,
@@ -104,13 +103,13 @@ export type ChannelPlugin<ResolvedAccount = any, Probe = unknown, Audit = unknow
status?: ChannelStatusAdapter<ResolvedAccount, Probe, Audit>;
gatewayMethods?: string[];
gateway?: ChannelGatewayAdapter<ResolvedAccount>;
// Login/logout and channel-auth only. Approval auth lives on approvalCapability.
auth?: ChannelAuthAdapter;
approvalCapability?: ChannelApprovalCapability;
elevated?: ChannelElevatedAdapter;
commands?: ChannelCommandAdapter;
lifecycle?: ChannelLifecycleAdapter;
secrets?: ChannelSecretsAdapter;
approvals?: ChannelApprovalAdapter;
allowlist?: ChannelAllowlistAdapter;
doctor?: ChannelDoctorAdapter;
bindings?: ChannelConfiguredBindingProvider;

View File

@@ -0,0 +1,213 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { type ChannelId, type ChannelPlugin } from "../channels/plugins/types.js";
import {
createSubsystemLogger,
runtimeForLogger,
type SubsystemLogger,
} from "../logging/subsystem.js";
import { createEmptyPluginRegistry, type PluginRegistry } from "../plugins/registry.js";
import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js";
import { createRuntimeChannel } from "../plugins/runtime/runtime-channel.js";
import type { PluginRuntime } from "../plugins/runtime/types.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
const hoisted = vi.hoisted(() => ({
startChannelApprovalHandlerBootstrap: vi.fn(async () => async () => {}),
}));
vi.mock("../infra/approval-handler-bootstrap.js", () => ({
startChannelApprovalHandlerBootstrap: hoisted.startChannelApprovalHandlerBootstrap,
}));
function createDeferred() {
let resolvePromise = () => {};
const promise = new Promise<void>((resolve) => {
resolvePromise = resolve;
});
return { promise, resolve: resolvePromise };
}
function createTestPlugin(params: {
startAccount: NonNullable<NonNullable<ChannelPlugin["gateway"]>["startAccount"]>;
}): ChannelPlugin {
return {
id: "discord",
meta: {
id: "discord",
label: "Discord",
selectionLabel: "Discord",
docsPath: "/channels/discord",
blurb: "test stub",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
resolveAccount: () => ({ enabled: true, configured: true }),
isEnabled: () => true,
describeAccount: () => ({
accountId: DEFAULT_ACCOUNT_ID,
enabled: true,
configured: true,
}),
},
approvalCapability: {
nativeRuntime: {
availability: {
isConfigured: vi.fn().mockReturnValue(true),
shouldHandle: vi.fn().mockReturnValue(true),
},
presentation: {
buildPendingPayload: vi.fn(),
buildResolvedResult: vi.fn(),
buildExpiredResult: vi.fn(),
},
transport: {
prepareTarget: vi.fn(),
deliverPending: vi.fn(),
},
},
},
gateway: {
startAccount: params.startAccount,
},
};
}
function installTestRegistry(plugin: ChannelPlugin) {
const registry = createEmptyPluginRegistry();
registry.channels.push({
pluginId: plugin.id,
source: "test",
plugin,
});
setActivePluginRegistry(registry);
}
function createManager(
createChannelManager: typeof import("./server-channels.js").createChannelManager,
options?: {
channelRuntime?: PluginRuntime["channel"];
},
) {
const log = createSubsystemLogger("gateway/server-channels-approval-bootstrap-test");
const channelLogs = { discord: log } as Record<ChannelId, SubsystemLogger>;
const runtime = runtimeForLogger(log);
const channelRuntimeEnvs = { discord: runtime } as unknown as Record<ChannelId, RuntimeEnv>;
return createChannelManager({
loadConfig: () => ({}),
channelLogs,
channelRuntimeEnvs,
...(options?.channelRuntime ? { channelRuntime: options.channelRuntime } : {}),
});
}
describe("server-channels approval bootstrap", () => {
let previousRegistry: PluginRegistry | null = null;
let createChannelManager: typeof import("./server-channels.js").createChannelManager;
beforeAll(async () => {
({ createChannelManager } = await import("./server-channels.js"));
});
beforeEach(() => {
previousRegistry = getActivePluginRegistry();
hoisted.startChannelApprovalHandlerBootstrap.mockReset();
});
afterEach(() => {
setActivePluginRegistry(previousRegistry ?? createEmptyPluginRegistry());
});
it("starts and stops the shared approval bootstrap with the channel lifecycle", async () => {
const channelRuntime = createRuntimeChannel();
const stopApprovalBootstrap = vi.fn(async () => {});
hoisted.startChannelApprovalHandlerBootstrap.mockResolvedValue(stopApprovalBootstrap);
const started = createDeferred();
const stopped = createDeferred();
const startAccount = vi.fn(
async ({
abortSignal,
channelRuntime,
}: Parameters<NonNullable<NonNullable<ChannelPlugin["gateway"]>["startAccount"]>>[0]) => {
channelRuntime?.runtimeContexts.register({
channelId: "discord",
accountId: DEFAULT_ACCOUNT_ID,
capability: "approval.native",
context: { token: "tracked" },
});
started.resolve();
await new Promise<void>((resolve) => {
abortSignal.addEventListener(
"abort",
() => {
stopped.resolve();
resolve();
},
{ once: true },
);
});
},
);
installTestRegistry(createTestPlugin({ startAccount }));
const manager = createManager(createChannelManager, { channelRuntime });
await manager.startChannels();
await started.promise;
expect(hoisted.startChannelApprovalHandlerBootstrap).toHaveBeenCalledWith(
expect.objectContaining({
plugin: expect.objectContaining({ id: "discord" }),
cfg: {},
accountId: DEFAULT_ACCOUNT_ID,
channelRuntime: expect.objectContaining({
runtimeContexts: expect.any(Object),
}),
}),
);
expect(
channelRuntime.runtimeContexts.get({
channelId: "discord",
accountId: DEFAULT_ACCOUNT_ID,
capability: "approval.native",
}),
).toEqual({ token: "tracked" });
await manager.stopChannel("discord", DEFAULT_ACCOUNT_ID);
await stopped.promise;
expect(stopApprovalBootstrap).toHaveBeenCalledTimes(1);
expect(
channelRuntime.runtimeContexts.get({
channelId: "discord",
accountId: DEFAULT_ACCOUNT_ID,
capability: "approval.native",
}),
).toBeUndefined();
});
it("keeps the account stopped when approval bootstrap startup fails", async () => {
const channelRuntime = createRuntimeChannel();
const startAccount = vi.fn(async () => {});
hoisted.startChannelApprovalHandlerBootstrap.mockRejectedValue(new Error("boom"));
installTestRegistry(createTestPlugin({ startAccount }));
const manager = createManager(createChannelManager, { channelRuntime });
await manager.startChannels();
expect(startAccount).not.toHaveBeenCalled();
const accountSnapshot =
manager.getRuntimeSnapshot().channelAccounts.discord?.[DEFAULT_ACCOUNT_ID];
expect(accountSnapshot).toEqual(
expect.objectContaining({
accountId: DEFAULT_ACCOUNT_ID,
running: false,
restartPending: false,
lastError: "boom",
}),
);
});
});

View File

@@ -7,6 +7,7 @@ import {
} from "../logging/subsystem.js";
import { createEmptyPluginRegistry, type PluginRegistry } from "../plugins/registry.js";
import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js";
import { createRuntimeChannel } from "../plugins/runtime/runtime-channel.js";
import type { PluginRuntime } from "../plugins/runtime/types.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
@@ -220,16 +221,20 @@ describe("server-channels auto restart", () => {
});
it("passes channelRuntime through channel gateway context when provided", async () => {
const channelRuntime = { marker: "channel-runtime" } as unknown as PluginRuntime["channel"];
const startAccount = vi.fn(async (ctx) => {
expect(ctx.channelRuntime).toBe(channelRuntime);
});
const channelRuntime = {
...createRuntimeChannel(),
marker: "channel-runtime",
} as PluginRuntime["channel"] & { marker: string };
const startAccount = vi.fn(async (_ctx: { channelRuntime?: PluginRuntime["channel"] }) => {});
installTestRegistry(createTestPlugin({ startAccount }));
const manager = createManager({ channelRuntime });
await manager.startChannels();
expect(startAccount).toHaveBeenCalledTimes(1);
const [ctx] = startAccount.mock.calls[0] ?? [];
expect(ctx?.channelRuntime).toMatchObject({ marker: "channel-runtime" });
expect(ctx?.channelRuntime).not.toBe(channelRuntime);
});
it("deduplicates concurrent start requests for the same account", async () => {
@@ -280,12 +285,11 @@ describe("server-channels auto restart", () => {
it("does not resolve channelRuntime until a channel starts", async () => {
const channelRuntime = {
...createRuntimeChannel(),
marker: "lazy-channel-runtime",
} as unknown as PluginRuntime["channel"];
} as PluginRuntime["channel"] & { marker: string };
const resolveChannelRuntime = vi.fn(() => channelRuntime);
const startAccount = vi.fn(async (ctx) => {
expect(ctx.channelRuntime).toBe(channelRuntime);
});
const startAccount = vi.fn(async (_ctx: { channelRuntime?: PluginRuntime["channel"] }) => {});
installTestRegistry(createTestPlugin({ startAccount }));
const manager = createManager({ resolveChannelRuntime });
@@ -299,6 +303,56 @@ describe("server-channels auto restart", () => {
expect(resolveChannelRuntime).toHaveBeenCalledTimes(1);
expect(startAccount).toHaveBeenCalledTimes(1);
const [ctx] = startAccount.mock.calls[0] ?? [];
expect(ctx?.channelRuntime).toMatchObject({ marker: "lazy-channel-runtime" });
expect(ctx?.channelRuntime).not.toBe(channelRuntime);
});
it("fails fast when channelRuntime is not a full plugin runtime surface", async () => {
installTestRegistry(createTestPlugin({ startAccount: vi.fn(async () => {}) }));
const manager = createManager({
channelRuntime: { marker: "partial-runtime" } as unknown as PluginRuntime["channel"],
});
await expect(manager.startChannel("discord", DEFAULT_ACCOUNT_ID)).rejects.toThrow(
"channelRuntime must provide runtimeContexts.register/get/watch; pass createPluginRuntime().channel or omit channelRuntime.",
);
await expect(manager.startChannel("discord", DEFAULT_ACCOUNT_ID)).rejects.toThrow(
"channelRuntime must provide runtimeContexts.register/get/watch; pass createPluginRuntime().channel or omit channelRuntime.",
);
});
it("keeps auto-restart running when scoped runtime cleanup throws", async () => {
const baseChannelRuntime = createRuntimeChannel();
const channelRuntime: PluginRuntime["channel"] = {
...baseChannelRuntime,
runtimeContexts: {
...baseChannelRuntime.runtimeContexts,
register: () => ({
dispose: () => {
throw new Error("cleanup boom");
},
}),
},
};
const startAccount = vi.fn(
async ({ channelRuntime }: { channelRuntime?: PluginRuntime["channel"] }) => {
channelRuntime?.runtimeContexts.register({
channelId: "discord",
accountId: DEFAULT_ACCOUNT_ID,
capability: "approval.native",
context: { token: "tracked" },
});
},
);
installTestRegistry(createTestPlugin({ startAccount }));
const manager = createManager({ channelRuntime });
await manager.startChannels();
await vi.advanceTimersByTimeAsync(30);
expect(startAccount.mock.calls.length).toBeGreaterThan(1);
});
it("continues starting later channels after one startup failure", async () => {

View File

@@ -2,7 +2,9 @@ import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import { type ChannelId, getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js";
import type { ChannelAccountSnapshot } from "../channels/plugins/types.js";
import type { OpenClawConfig } from "../config/config.js";
import { startChannelApprovalHandlerBootstrap } from "../infra/approval-handler-bootstrap.js";
import { type BackoffPolicy, computeBackoff, sleepWithAbort } from "../infra/backoff.js";
import { createTaskScopedChannelRuntime } from "../infra/channel-runtime-context.js";
import { formatErrorMessage } from "../infra/errors.js";
import { resetDirectoryCache } from "../infra/outbound/target-resolver.js";
import type { createSubsystemLogger } from "../logging/subsystem.js";
@@ -108,7 +110,8 @@ type ChannelManagerOptions = {
* because they can directly import internal modules from the monorepo.
*
* This field is optional - omitting it maintains backward compatibility
* with existing channels.
* with existing channels. When provided, it must be a real
* `createPluginRuntime().channel` surface; partial stubs are not supported.
*
* @example
* ```typescript
@@ -131,7 +134,8 @@ type ChannelManagerOptions = {
*
* Use this when the caller wants to avoid instantiating the full plugin channel
* runtime during gateway startup. The manager only needs the runtime surface once
* a channel account actually starts.
* a channel account actually starts. The resolved value must be a real
* `createPluginRuntime().channel` surface.
*/
resolveChannelRuntime?: () => PluginRuntime["channel"];
};
@@ -296,8 +300,31 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
const abort = new AbortController();
store.aborts.set(id, abort);
let handedOffTask = false;
const log = channelLogs[channelId];
let scopedChannelRuntime: ReturnType<typeof createTaskScopedChannelRuntime> | null = null;
let channelRuntimeForTask: PluginRuntime["channel"] | undefined;
let stopApprovalBootstrap: () => Promise<void> = async () => {};
const stopTaskScopedApprovalRuntime = async () => {
const scopedRuntime = scopedChannelRuntime;
scopedChannelRuntime = null;
const stopBootstrap = stopApprovalBootstrap;
stopApprovalBootstrap = async () => {};
scopedRuntime?.dispose();
await stopBootstrap();
};
const cleanupTaskScopedApprovalRuntime = async (label: string) => {
try {
await stopTaskScopedApprovalRuntime();
} catch (error) {
log.error?.(`[${id}] ${label}: ${formatErrorMessage(error)}`);
}
};
try {
scopedChannelRuntime = createTaskScopedChannelRuntime({
channelRuntime: getChannelRuntime(),
});
channelRuntimeForTask = scopedChannelRuntime.channelRuntime;
const account = plugin.config.resolveAccount(cfg, id);
const enabled = plugin.config.isEnabled
? plugin.config.isEnabled(account, cfg)
@@ -348,6 +375,13 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
if (!preserveRestartAttempts) {
restartAttempts.delete(rKey);
}
stopApprovalBootstrap = await startChannelApprovalHandlerBootstrap({
plugin,
cfg,
accountId: id,
channelRuntime: channelRuntimeForTask,
logger: log,
});
setRuntime(channelId, id, {
accountId: id,
enabled: true,
@@ -358,27 +392,27 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
lastError: null,
reconnectAttempts: preserveRestartAttempts ? (restartAttempts.get(rKey) ?? 0) : 0,
});
const log = channelLogs[channelId];
const resolvedChannelRuntime = getChannelRuntime();
const task = startAccount({
cfg,
accountId: id,
account,
runtime: channelRuntimeEnvs[channelId],
abortSignal: abort.signal,
log,
getStatus: () => getRuntime(channelId, id),
setStatus: (next) => setRuntime(channelId, id, next),
...(resolvedChannelRuntime ? { channelRuntime: resolvedChannelRuntime } : {}),
});
const trackedPromise = Promise.resolve(task)
const task = Promise.resolve().then(() =>
startAccount({
cfg,
accountId: id,
account,
runtime: channelRuntimeEnvs[channelId],
abortSignal: abort.signal,
log,
getStatus: () => getRuntime(channelId, id),
setStatus: (next) => setRuntime(channelId, id, next),
...(channelRuntimeForTask ? { channelRuntime: channelRuntimeForTask } : {}),
}),
);
const trackedPromise = task
.catch((err) => {
const message = formatErrorMessage(err);
setRuntime(channelId, id, { accountId: id, lastError: message });
log.error?.(`[${id}] channel exited: ${message}`);
})
.finally(() => {
.finally(async () => {
await cleanupTaskScopedApprovalRuntime("channel cleanup failed");
setRuntime(channelId, id, {
accountId: id,
running: false,
@@ -438,11 +472,24 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
});
handedOffTask = true;
store.tasks.set(id, trackedPromise);
} catch (error) {
if (!handedOffTask) {
setRuntime(channelId, id, {
accountId: id,
running: false,
restartPending: false,
lastError: formatErrorMessage(error),
});
}
throw error;
} finally {
resolveStart?.();
if (store.starting.get(id) === startGate) {
store.starting.delete(id);
}
if (!handedOffTask) {
await cleanupTaskScopedApprovalRuntime("channel startup cleanup failed");
}
if (!handedOffTask && store.aborts.get(id) === abort) {
store.aborts.delete(id);
}

View File

@@ -38,6 +38,7 @@ import type { GatewayRequestHandlers } from "./types.js";
const APPROVAL_ALLOW_ALWAYS_UNAVAILABLE_DETAILS = {
reason: "APPROVAL_ALLOW_ALWAYS_UNAVAILABLE",
} as const;
const RESERVED_PLUGIN_APPROVAL_ID_PREFIX = "plugin:";
type ExecApprovalIosPushDelivery = {
handleRequested?: (request: ExecApprovalRequest) => Promise<boolean>;
@@ -167,6 +168,17 @@ export function createExecApprovalHandlers(
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "command is required"));
return;
}
if (explicitId?.startsWith(RESERVED_PLUGIN_APPROVAL_ID_PREFIX)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`approval ids starting with ${RESERVED_PLUGIN_APPROVAL_ID_PREFIX} are reserved`,
),
);
return;
}
if (
host === "node" &&
(!Array.isArray(effectiveCommandArgv) || effectiveCommandArgv.length === 0)

View File

@@ -989,6 +989,26 @@ describe("exec approval handlers", () => {
expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
});
it("rejects explicit approval ids with the reserved plugin prefix", async () => {
const { handlers, respond, context } = createExecApprovalFixture();
await requestExecApproval({
handlers,
respond,
context,
params: { id: "plugin:approval-123", host: "gateway" },
});
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
code: "INVALID_REQUEST",
message: "approval ids starting with plugin: are reserved",
}),
);
});
it("accepts unique short approval id prefixes", async () => {
const manager = new ExecApprovalManager();
const handlers = createExecApprovalHandlers(manager);

View File

@@ -0,0 +1,143 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveApprovalOverGateway } from "./approval-gateway-resolver.js";
const hoisted = vi.hoisted(() => ({
createOperatorApprovalsGatewayClient: vi.fn(),
clientStart: vi.fn(),
clientStop: vi.fn(),
clientStopAndWait: vi.fn(),
clientRequest: vi.fn(),
}));
vi.mock("../gateway/operator-approvals-client.js", () => ({
createOperatorApprovalsGatewayClient: hoisted.createOperatorApprovalsGatewayClient,
}));
function createGatewayClient(params: {
stopAndWaitRejects?: boolean;
requestImpl?: typeof hoisted.clientRequest;
}) {
const request = params.requestImpl ?? hoisted.clientRequest;
return {
start: () => {
hoisted.clientStart();
},
stop: hoisted.clientStop,
stopAndWait: params.stopAndWaitRejects
? vi.fn(async () => {
hoisted.clientStopAndWait();
throw new Error("close failed");
})
: vi.fn(async () => {
hoisted.clientStopAndWait();
}),
request,
};
}
describe("resolveApprovalOverGateway", () => {
beforeEach(() => {
hoisted.clientStart.mockReset();
hoisted.clientStop.mockReset();
hoisted.clientStopAndWait.mockReset();
hoisted.clientRequest.mockReset().mockResolvedValue({ ok: true });
hoisted.createOperatorApprovalsGatewayClient.mockReset().mockImplementation(async (params) => {
const client = createGatewayClient({});
queueMicrotask(() => {
params.onHelloOk?.({} as never);
});
return client;
});
});
it("routes exec approvals through exec.approval.resolve", async () => {
await resolveApprovalOverGateway({
cfg: { gateway: { auth: { token: "cfg-token" } } } as never,
approvalId: "approval-1",
decision: "allow-once",
gatewayUrl: "ws://gateway.example.test",
clientDisplayName: "Discord approval (default)",
});
expect(hoisted.createOperatorApprovalsGatewayClient).toHaveBeenCalledWith(
expect.objectContaining({
config: { gateway: { auth: { token: "cfg-token" } } },
gatewayUrl: "ws://gateway.example.test",
clientDisplayName: "Discord approval (default)",
}),
);
expect(hoisted.clientStart).toHaveBeenCalledTimes(1);
expect(hoisted.clientRequest).toHaveBeenCalledWith("exec.approval.resolve", {
id: "approval-1",
decision: "allow-once",
});
expect(hoisted.clientStopAndWait).toHaveBeenCalledTimes(1);
});
it("routes plugin approvals through plugin.approval.resolve", async () => {
await resolveApprovalOverGateway({
cfg: {} as never,
approvalId: "plugin:approval-1",
decision: "deny",
});
expect(hoisted.clientRequest).toHaveBeenCalledTimes(1);
expect(hoisted.clientRequest).toHaveBeenCalledWith("plugin.approval.resolve", {
id: "plugin:approval-1",
decision: "deny",
});
});
it("falls back to plugin.approval.resolve only for not-found exec approvals when enabled", async () => {
const notFoundError = Object.assign(new Error("unknown or expired approval id"), {
gatewayCode: "APPROVAL_NOT_FOUND",
});
hoisted.clientRequest.mockRejectedValueOnce(notFoundError).mockResolvedValueOnce({ ok: true });
await resolveApprovalOverGateway({
cfg: {} as never,
approvalId: "approval-1",
decision: "allow-always",
allowPluginFallback: true,
});
expect(hoisted.clientRequest.mock.calls).toEqual([
["exec.approval.resolve", { id: "approval-1", decision: "allow-always" }],
["plugin.approval.resolve", { id: "approval-1", decision: "allow-always" }],
]);
});
it("does not fall back for non-not-found exec approval failures", async () => {
hoisted.clientRequest.mockRejectedValueOnce(new Error("permission denied"));
await expect(
resolveApprovalOverGateway({
cfg: {} as never,
approvalId: "approval-1",
decision: "deny",
allowPluginFallback: true,
}),
).rejects.toThrow("permission denied");
expect(hoisted.clientRequest).toHaveBeenCalledTimes(1);
});
it("falls back to stop when stopAndWait rejects", async () => {
hoisted.createOperatorApprovalsGatewayClient.mockReset().mockImplementation(async (params) => {
const client = createGatewayClient({ stopAndWaitRejects: true });
queueMicrotask(() => {
params.onHelloOk?.({} as never);
});
return client;
});
await resolveApprovalOverGateway({
cfg: {} as never,
approvalId: "approval-1",
decision: "allow-once",
});
expect(hoisted.clientStopAndWait).toHaveBeenCalledTimes(1);
expect(hoisted.clientStop).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,79 @@
import type { OpenClawConfig } from "../config/config.js";
import { createOperatorApprovalsGatewayClient } from "../gateway/operator-approvals-client.js";
import { isApprovalNotFoundError } from "./approval-errors.js";
import type { ExecApprovalDecision } from "./exec-approvals.js";
export type ResolveApprovalOverGatewayParams = {
cfg: OpenClawConfig;
approvalId: string;
decision: ExecApprovalDecision;
senderId?: string | null;
allowPluginFallback?: boolean;
gatewayUrl?: string;
clientDisplayName?: string;
};
export async function resolveApprovalOverGateway(
params: ResolveApprovalOverGatewayParams,
): Promise<void> {
let readySettled = false;
let resolveReady!: () => void;
let rejectReady!: (err: unknown) => void;
const ready = new Promise<void>((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
});
const markReady = () => {
if (readySettled) {
return;
}
readySettled = true;
resolveReady();
};
const failReady = (err: unknown) => {
if (readySettled) {
return;
}
readySettled = true;
rejectReady(err);
};
const gatewayClient = await createOperatorApprovalsGatewayClient({
config: params.cfg,
gatewayUrl: params.gatewayUrl,
clientDisplayName:
params.clientDisplayName ?? `Approval (${params.senderId?.trim() || "unknown"})`,
onHelloOk: markReady,
onConnectError: failReady,
onClose: (code, reason) => {
failReady(new Error(`gateway closed (${code}): ${reason}`));
},
});
try {
gatewayClient.start();
await ready;
const requestResolve = async (method: "exec.approval.resolve" | "plugin.approval.resolve") => {
await gatewayClient.request(method, {
id: params.approvalId,
decision: params.decision,
});
};
if (params.approvalId.startsWith("plugin:")) {
await requestResolve("plugin.approval.resolve");
return;
}
try {
await requestResolve("exec.approval.resolve");
} catch (err) {
if (!params.allowPluginFallback || !isApprovalNotFoundError(err)) {
throw err;
}
await requestResolve("plugin.approval.resolve");
}
} finally {
await gatewayClient.stopAndWait().catch(() => {
gatewayClient.stop();
});
}
}

View File

@@ -0,0 +1,406 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createRuntimeChannel } from "../plugins/runtime/runtime-channel.js";
import { startChannelApprovalHandlerBootstrap } from "./approval-handler-bootstrap.js";
const { createChannelApprovalHandlerFromCapability } = vi.hoisted(() => ({
createChannelApprovalHandlerFromCapability: vi.fn(),
}));
vi.mock("./approval-handler-runtime.js", async () => {
const actual = await vi.importActual<typeof import("./approval-handler-runtime.js")>(
"./approval-handler-runtime.js",
);
return {
...actual,
createChannelApprovalHandlerFromCapability,
};
});
describe("startChannelApprovalHandlerBootstrap", () => {
beforeEach(() => {
createChannelApprovalHandlerFromCapability.mockReset();
vi.useRealTimers();
});
const flushTransitions = async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
};
it("starts and stops the shared approval handler from runtime context registration", async () => {
const channelRuntime = createRuntimeChannel();
const start = vi.fn().mockResolvedValue(undefined);
const stop = vi.fn().mockResolvedValue(undefined);
createChannelApprovalHandlerFromCapability.mockResolvedValue({
start,
stop,
});
const cleanup = await startChannelApprovalHandlerBootstrap({
plugin: {
id: "slack",
meta: { label: "Slack" },
approvalCapability: {
nativeRuntime: {
availability: {
isConfigured: vi.fn().mockReturnValue(true),
shouldHandle: vi.fn().mockReturnValue(true),
},
presentation: {
buildPendingPayload: vi.fn(),
buildResolvedResult: vi.fn(),
buildExpiredResult: vi.fn(),
},
transport: {
prepareTarget: vi.fn(),
deliverPending: vi.fn(),
},
},
},
} as never,
cfg: {} as never,
accountId: "default",
channelRuntime,
});
const lease = channelRuntime.runtimeContexts.register({
channelId: "slack",
accountId: "default",
capability: "approval.native",
context: { app: { ok: true } },
});
await flushTransitions();
expect(createChannelApprovalHandlerFromCapability).toHaveBeenCalled();
expect(start).toHaveBeenCalledTimes(1);
lease.dispose();
await flushTransitions();
expect(stop).toHaveBeenCalledTimes(1);
await cleanup();
});
it("starts immediately when the runtime context was already registered", async () => {
const channelRuntime = createRuntimeChannel();
const start = vi.fn().mockResolvedValue(undefined);
const stop = vi.fn().mockResolvedValue(undefined);
createChannelApprovalHandlerFromCapability.mockResolvedValue({
start,
stop,
});
const lease = channelRuntime.runtimeContexts.register({
channelId: "slack",
accountId: "default",
capability: "approval.native",
context: { app: { ok: true } },
});
const cleanup = await startChannelApprovalHandlerBootstrap({
plugin: {
id: "slack",
meta: { label: "Slack" },
approvalCapability: {
nativeRuntime: {
availability: {
isConfigured: vi.fn().mockReturnValue(true),
shouldHandle: vi.fn().mockReturnValue(true),
},
presentation: {
buildPendingPayload: vi.fn(),
buildResolvedResult: vi.fn(),
buildExpiredResult: vi.fn(),
},
transport: {
prepareTarget: vi.fn(),
deliverPending: vi.fn(),
},
},
},
} as never,
cfg: {} as never,
accountId: "default",
channelRuntime,
});
expect(createChannelApprovalHandlerFromCapability).toHaveBeenCalledTimes(1);
expect(start).toHaveBeenCalledTimes(1);
await cleanup();
expect(stop).toHaveBeenCalledTimes(1);
lease.dispose();
});
it("does not start a handler after the runtime context is unregistered mid-boot", async () => {
const channelRuntime = createRuntimeChannel();
let resolveRuntime:
| ((value: { start: ReturnType<typeof vi.fn>; stop: ReturnType<typeof vi.fn> }) => void)
| undefined;
const runtimePromise = new Promise<{
start: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
}>((resolve) => {
resolveRuntime = resolve;
});
createChannelApprovalHandlerFromCapability.mockReturnValue(runtimePromise);
const cleanup = await startChannelApprovalHandlerBootstrap({
plugin: {
id: "slack",
meta: { label: "Slack" },
approvalCapability: {
nativeRuntime: {
availability: {
isConfigured: vi.fn().mockReturnValue(true),
shouldHandle: vi.fn().mockReturnValue(true),
},
presentation: {
buildPendingPayload: vi.fn(),
buildResolvedResult: vi.fn(),
buildExpiredResult: vi.fn(),
},
transport: {
prepareTarget: vi.fn(),
deliverPending: vi.fn(),
},
},
},
} as never,
cfg: {} as never,
accountId: "default",
channelRuntime,
});
const lease = channelRuntime.runtimeContexts.register({
channelId: "slack",
accountId: "default",
capability: "approval.native",
context: { app: { ok: true } },
});
await flushTransitions();
const start = vi.fn().mockResolvedValue(undefined);
const stop = vi.fn().mockResolvedValue(undefined);
lease.dispose();
resolveRuntime?.({ start, stop });
await flushTransitions();
expect(start).not.toHaveBeenCalled();
expect(stop).toHaveBeenCalledTimes(1);
await cleanup();
});
it("restarts the shared approval handler when the runtime context is replaced", async () => {
const channelRuntime = createRuntimeChannel();
const startFirst = vi.fn().mockResolvedValue(undefined);
const stopFirst = vi.fn().mockResolvedValue(undefined);
const startSecond = vi.fn().mockResolvedValue(undefined);
const stopSecond = vi.fn().mockResolvedValue(undefined);
createChannelApprovalHandlerFromCapability
.mockResolvedValueOnce({
start: startFirst,
stop: stopFirst,
})
.mockResolvedValueOnce({
start: startSecond,
stop: stopSecond,
});
const cleanup = await startChannelApprovalHandlerBootstrap({
plugin: {
id: "slack",
meta: { label: "Slack" },
approvalCapability: {
nativeRuntime: {
availability: {
isConfigured: vi.fn().mockReturnValue(true),
shouldHandle: vi.fn().mockReturnValue(true),
},
presentation: {
buildPendingPayload: vi.fn(),
buildResolvedResult: vi.fn(),
buildExpiredResult: vi.fn(),
},
transport: {
prepareTarget: vi.fn(),
deliverPending: vi.fn(),
},
},
},
} as never,
cfg: {} as never,
accountId: "default",
channelRuntime,
});
const firstLease = channelRuntime.runtimeContexts.register({
channelId: "slack",
accountId: "default",
capability: "approval.native",
context: { app: { ok: "first" } },
});
await flushTransitions();
const secondLease = channelRuntime.runtimeContexts.register({
channelId: "slack",
accountId: "default",
capability: "approval.native",
context: { app: { ok: "second" } },
});
await flushTransitions();
expect(createChannelApprovalHandlerFromCapability).toHaveBeenCalledTimes(2);
expect(startFirst).toHaveBeenCalledTimes(1);
expect(stopFirst).toHaveBeenCalledTimes(1);
expect(startSecond).toHaveBeenCalledTimes(1);
secondLease.dispose();
await flushTransitions();
expect(stopSecond).toHaveBeenCalledTimes(1);
firstLease.dispose();
await cleanup();
});
it("retries registered-context startup failures until the handler starts", async () => {
vi.useFakeTimers();
const channelRuntime = createRuntimeChannel();
const start = vi.fn().mockRejectedValueOnce(new Error("boom")).mockResolvedValueOnce(undefined);
const stop = vi.fn().mockResolvedValue(undefined);
const logger = {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
child: vi.fn(),
isEnabled: vi.fn().mockReturnValue(true),
isVerboseEnabled: vi.fn().mockReturnValue(false),
verbose: vi.fn(),
};
createChannelApprovalHandlerFromCapability
.mockResolvedValueOnce({ start, stop })
.mockResolvedValueOnce({ start, stop });
const cleanup = await startChannelApprovalHandlerBootstrap({
plugin: {
id: "slack",
meta: { label: "Slack" },
approvalCapability: {
nativeRuntime: {
availability: {
isConfigured: vi.fn().mockReturnValue(true),
shouldHandle: vi.fn().mockReturnValue(true),
},
presentation: {
buildPendingPayload: vi.fn(),
buildResolvedResult: vi.fn(),
buildExpiredResult: vi.fn(),
},
transport: {
prepareTarget: vi.fn(),
deliverPending: vi.fn(),
},
},
},
} as never,
cfg: {} as never,
accountId: "default",
channelRuntime,
logger: logger as never,
});
channelRuntime.runtimeContexts.register({
channelId: "slack",
accountId: "default",
capability: "approval.native",
context: { app: { ok: true } },
});
await flushTransitions();
expect(start).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1_000);
await flushTransitions();
expect(createChannelApprovalHandlerFromCapability).toHaveBeenCalledTimes(2);
expect(start).toHaveBeenCalledTimes(2);
expect(stop).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith(
"failed to start native approval handler: Error: boom",
);
await cleanup();
});
it("does not let a stale retry stop a newer active handler", async () => {
vi.useFakeTimers();
const channelRuntime = createRuntimeChannel();
const firstStart = vi.fn().mockRejectedValueOnce(new Error("boom"));
const firstStop = vi.fn().mockResolvedValue(undefined);
const secondStart = vi.fn().mockResolvedValue(undefined);
const secondStop = vi.fn().mockResolvedValue(undefined);
createChannelApprovalHandlerFromCapability
.mockResolvedValueOnce({ start: firstStart, stop: firstStop })
.mockResolvedValueOnce({ start: secondStart, stop: secondStop })
.mockResolvedValueOnce({ start: secondStart, stop: secondStop });
const cleanup = await startChannelApprovalHandlerBootstrap({
plugin: {
id: "slack",
meta: { label: "Slack" },
approvalCapability: {
nativeRuntime: {
availability: {
isConfigured: vi.fn().mockReturnValue(true),
shouldHandle: vi.fn().mockReturnValue(true),
},
presentation: {
buildPendingPayload: vi.fn(),
buildResolvedResult: vi.fn(),
buildExpiredResult: vi.fn(),
},
transport: {
prepareTarget: vi.fn(),
deliverPending: vi.fn(),
},
},
},
} as never,
cfg: {} as never,
accountId: "default",
channelRuntime,
});
channelRuntime.runtimeContexts.register({
channelId: "slack",
accountId: "default",
capability: "approval.native",
context: { app: { ok: "first" } },
});
await flushTransitions();
expect(firstStart).toHaveBeenCalledTimes(1);
channelRuntime.runtimeContexts.register({
channelId: "slack",
accountId: "default",
capability: "approval.native",
context: { app: { ok: "second" } },
});
await flushTransitions();
expect(secondStart).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1_000);
await flushTransitions();
expect(firstStop).toHaveBeenCalledTimes(1);
expect(secondStart).toHaveBeenCalledTimes(1);
expect(secondStop).not.toHaveBeenCalled();
await cleanup();
});
});

View File

@@ -0,0 +1,167 @@
import { resolveChannelApprovalCapability } from "../channels/plugins/approvals.js";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { OpenClawConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import type { PluginRuntime } from "../plugins/runtime/types.js";
import {
CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY,
createChannelApprovalHandlerFromCapability,
type ChannelApprovalHandler,
} from "./approval-handler-runtime.js";
import {
getChannelRuntimeContext,
watchChannelRuntimeContexts,
} from "./channel-runtime-context.js";
type ApprovalBootstrapHandler = ChannelApprovalHandler;
const APPROVAL_HANDLER_BOOTSTRAP_RETRY_MS = 1_000;
export async function startChannelApprovalHandlerBootstrap(params: {
plugin: Pick<ChannelPlugin, "id" | "meta" | "approvalCapability">;
cfg: OpenClawConfig;
accountId: string;
channelRuntime?: PluginRuntime["channel"];
logger?: ReturnType<typeof createSubsystemLogger>;
}): Promise<() => Promise<void>> {
const capability = resolveChannelApprovalCapability(params.plugin);
if (!capability?.nativeRuntime || !params.channelRuntime) {
return async () => {};
}
const channelLabel = params.plugin.meta.label || params.plugin.id;
const logger = params.logger ?? createSubsystemLogger(`${params.plugin.id}/approval-bootstrap`);
let activeGeneration = 0;
let activeHandler: ApprovalBootstrapHandler | null = null;
let retryTimer: NodeJS.Timeout | null = null;
const invalidateActiveHandler = () => {
activeGeneration += 1;
};
const clearRetryTimer = () => {
if (!retryTimer) {
return;
}
clearTimeout(retryTimer);
retryTimer = null;
};
const stopHandler = async () => {
const handler = activeHandler;
activeHandler = null;
if (!handler) {
return;
}
await handler.stop();
};
const startHandlerForContext = async (context: unknown, generation: number) => {
if (generation !== activeGeneration) {
return;
}
await stopHandler();
if (generation !== activeGeneration) {
return;
}
const handler = await createChannelApprovalHandlerFromCapability({
capability,
label: `${params.plugin.id}/native-approvals`,
clientDisplayName: `${channelLabel} Native Approvals (${params.accountId})`,
channel: params.plugin.id,
channelLabel,
cfg: params.cfg,
accountId: params.accountId,
context,
});
if (!handler) {
return;
}
if (generation !== activeGeneration) {
await handler.stop().catch(() => {});
return;
}
activeHandler = handler as ApprovalBootstrapHandler;
try {
await handler.start();
} catch (error) {
if (activeHandler === handler) {
activeHandler = null;
}
await handler.stop().catch(() => {});
throw error;
}
};
const spawn = (label: string, promise: Promise<void>) => {
void promise.catch((error) => {
logger.error(`${label}: ${String(error)}`);
});
};
const scheduleRetryForContext = (context: unknown, generation: number) => {
if (generation !== activeGeneration) {
return;
}
clearRetryTimer();
retryTimer = setTimeout(() => {
retryTimer = null;
if (generation !== activeGeneration) {
return;
}
spawn(
"failed to retry native approval handler",
startHandlerForRegisteredContext(context, generation),
);
}, APPROVAL_HANDLER_BOOTSTRAP_RETRY_MS);
retryTimer.unref?.();
};
const startHandlerForRegisteredContext = async (context: unknown, generation: number) => {
try {
await startHandlerForContext(context, generation);
} catch (error) {
if (generation === activeGeneration) {
logger.error(`failed to start native approval handler: ${String(error)}`);
scheduleRetryForContext(context, generation);
}
}
};
const unsubscribe =
watchChannelRuntimeContexts({
channelRuntime: params.channelRuntime,
channelId: params.plugin.id,
accountId: params.accountId,
capability: CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY,
onEvent: (event) => {
if (event.type === "registered") {
clearRetryTimer();
invalidateActiveHandler();
const generation = activeGeneration;
spawn(
"failed to start native approval handler",
startHandlerForRegisteredContext(event.context, generation),
);
return;
}
clearRetryTimer();
invalidateActiveHandler();
spawn("failed to stop native approval handler", stopHandler());
},
}) ?? (() => {});
const existingContext = getChannelRuntimeContext({
channelRuntime: params.channelRuntime,
channelId: params.plugin.id,
accountId: params.accountId,
capability: CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY,
});
if (existingContext !== undefined) {
clearRetryTimer();
invalidateActiveHandler();
await startHandlerForContext(existingContext, activeGeneration);
}
return async () => {
unsubscribe();
clearRetryTimer();
invalidateActiveHandler();
await stopHandler();
};
}

View File

@@ -0,0 +1,462 @@
import { describe, expect, it, vi } from "vitest";
import {
createChannelApprovalHandlerFromCapability,
createLazyChannelApprovalNativeRuntimeAdapter,
} from "./approval-handler-runtime.js";
describe("createChannelApprovalHandlerFromCapability", () => {
it("returns null when the capability does not expose a native runtime", async () => {
await expect(
createChannelApprovalHandlerFromCapability({
capability: {},
label: "test/approval-handler",
clientDisplayName: "Test Approval Handler",
channel: "test",
channelLabel: "Test",
cfg: {} as never,
}),
).resolves.toBeNull();
});
it("returns a runtime when the capability exposes a native runtime", async () => {
const runtime = await createChannelApprovalHandlerFromCapability({
capability: {
nativeRuntime: {
availability: {
isConfigured: vi.fn().mockReturnValue(true),
shouldHandle: vi.fn().mockReturnValue(true),
},
presentation: {
buildPendingPayload: vi.fn(),
buildResolvedResult: vi.fn(),
buildExpiredResult: vi.fn(),
},
transport: {
prepareTarget: vi.fn(),
deliverPending: vi.fn(),
},
},
},
label: "test/approval-handler",
clientDisplayName: "Test Approval Handler",
channel: "test",
channelLabel: "Test",
cfg: { channels: {} } as never,
});
expect(runtime).not.toBeNull();
});
it("preserves the original request and resolved approval kind when stop-time cleanup unbinds", async () => {
const unbindPending = vi.fn();
const runtime = await createChannelApprovalHandlerFromCapability({
capability: {
native: {
describeDeliveryCapabilities: vi.fn().mockReturnValue({
enabled: true,
preferredSurface: "origin",
supportsOriginSurface: true,
supportsApproverDmSurface: false,
notifyOriginWhenDmOnly: false,
}),
resolveOriginTarget: vi.fn().mockReturnValue({ to: "origin-chat" }),
},
nativeRuntime: {
resolveApprovalKind: vi.fn().mockReturnValue("plugin"),
availability: {
isConfigured: vi.fn().mockReturnValue(true),
shouldHandle: vi.fn().mockReturnValue(true),
},
presentation: {
buildPendingPayload: vi.fn().mockResolvedValue({ text: "pending" }),
buildResolvedResult: vi.fn(),
buildExpiredResult: vi.fn(),
},
transport: {
prepareTarget: vi.fn().mockResolvedValue({
dedupeKey: "origin-chat",
target: { to: "origin-chat" },
}),
deliverPending: vi.fn().mockResolvedValue({ messageId: "1" }),
},
interactions: {
bindPending: vi.fn().mockResolvedValue({ bindingId: "bound" }),
unbindPending,
},
},
},
label: "test/approval-handler",
clientDisplayName: "Test Approval Handler",
channel: "test",
channelLabel: "Test",
cfg: { channels: {} } as never,
});
expect(runtime).not.toBeNull();
const request = {
id: "custom:1",
expiresAtMs: Date.now() + 60_000,
request: {
turnSourceChannel: "test",
turnSourceTo: "origin-chat",
},
} as never;
await runtime?.handleRequested(request);
await runtime?.stop();
expect(unbindPending).toHaveBeenCalledWith(
expect.objectContaining({
request,
approvalKind: "plugin",
}),
);
});
it("unbinds and finalizes every prior pending delivery when the same approval id is requested again", async () => {
const unbindPending = vi.fn();
const buildResolvedResult = vi.fn().mockResolvedValue({ kind: "leave" });
const runtime = await createChannelApprovalHandlerFromCapability({
capability: {
native: {
describeDeliveryCapabilities: vi.fn().mockReturnValue({
enabled: true,
preferredSurface: "origin",
supportsOriginSurface: true,
supportsApproverDmSurface: false,
notifyOriginWhenDmOnly: false,
}),
resolveOriginTarget: vi.fn().mockReturnValue({ to: "origin-chat" }),
},
nativeRuntime: {
availability: {
isConfigured: vi.fn().mockReturnValue(true),
shouldHandle: vi.fn().mockReturnValue(true),
},
presentation: {
buildPendingPayload: vi.fn().mockResolvedValue({ text: "pending" }),
buildResolvedResult,
buildExpiredResult: vi.fn(),
},
transport: {
prepareTarget: vi.fn().mockResolvedValue({
dedupeKey: "origin-chat",
target: { to: "origin-chat" },
}),
deliverPending: vi
.fn()
.mockResolvedValueOnce({ messageId: "1" })
.mockResolvedValueOnce({ messageId: "2" }),
},
interactions: {
bindPending: vi
.fn()
.mockResolvedValueOnce({ bindingId: "bound-1" })
.mockResolvedValueOnce({ bindingId: "bound-2" }),
unbindPending,
},
},
},
label: "test/approval-handler",
clientDisplayName: "Test Approval Handler",
channel: "test",
channelLabel: "Test",
cfg: { channels: {} } as never,
});
expect(runtime).not.toBeNull();
const request = {
id: "exec:1",
expiresAtMs: Date.now() + 60_000,
request: {
command: "echo hi",
turnSourceChannel: "test",
turnSourceTo: "origin-chat",
},
} as never;
await runtime?.handleRequested(request);
await runtime?.handleRequested(request);
await runtime?.handleResolved({
id: "exec:1",
decision: "approved",
resolvedBy: "operator",
} as never);
expect(unbindPending).toHaveBeenCalledTimes(2);
expect(unbindPending).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
entry: { messageId: "1" },
binding: { bindingId: "bound-1" },
request,
}),
);
expect(unbindPending).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
entry: { messageId: "2" },
binding: { bindingId: "bound-2" },
request,
}),
);
expect(buildResolvedResult).toHaveBeenCalledTimes(2);
});
it("continues finalizing later entries when one resolved entry cleanup throws", async () => {
const unbindPending = vi
.fn()
.mockRejectedValueOnce(new Error("unbind failed"))
.mockResolvedValueOnce(undefined);
const buildResolvedResult = vi.fn().mockResolvedValue({ kind: "leave" });
const runtime = await createChannelApprovalHandlerFromCapability({
capability: {
native: {
describeDeliveryCapabilities: vi.fn().mockReturnValue({
enabled: true,
preferredSurface: "origin",
supportsOriginSurface: true,
supportsApproverDmSurface: false,
notifyOriginWhenDmOnly: false,
}),
resolveOriginTarget: vi.fn().mockReturnValue({ to: "origin-chat" }),
},
nativeRuntime: {
availability: {
isConfigured: vi.fn().mockReturnValue(true),
shouldHandle: vi.fn().mockReturnValue(true),
},
presentation: {
buildPendingPayload: vi.fn().mockResolvedValue({ text: "pending" }),
buildResolvedResult,
buildExpiredResult: vi.fn(),
},
transport: {
prepareTarget: vi.fn().mockResolvedValue({
dedupeKey: "origin-chat",
target: { to: "origin-chat" },
}),
deliverPending: vi
.fn()
.mockResolvedValueOnce({ messageId: "1" })
.mockResolvedValueOnce({ messageId: "2" }),
},
interactions: {
bindPending: vi
.fn()
.mockResolvedValueOnce({ bindingId: "bound-1" })
.mockResolvedValueOnce({ bindingId: "bound-2" }),
unbindPending,
},
},
},
label: "test/approval-handler",
clientDisplayName: "Test Approval Handler",
channel: "test",
channelLabel: "Test",
cfg: { channels: {} } as never,
});
const request = {
id: "exec:2",
expiresAtMs: Date.now() + 60_000,
request: {
command: "echo hi",
turnSourceChannel: "test",
turnSourceTo: "origin-chat",
},
} as never;
await runtime?.handleRequested(request);
await runtime?.handleRequested(request);
await expect(
runtime?.handleResolved({
id: "exec:2",
decision: "approved",
resolvedBy: "operator",
} as never),
).resolves.toBeUndefined();
expect(unbindPending).toHaveBeenCalledTimes(2);
expect(buildResolvedResult).toHaveBeenCalledTimes(1);
expect(buildResolvedResult).toHaveBeenCalledWith(
expect.objectContaining({
entry: { messageId: "2" },
}),
);
});
it("continues stop-time unbind cleanup when one binding throws", async () => {
const unbindPending = vi
.fn()
.mockRejectedValueOnce(new Error("unbind failed"))
.mockResolvedValueOnce(undefined);
const runtime = await createChannelApprovalHandlerFromCapability({
capability: {
native: {
describeDeliveryCapabilities: vi.fn().mockReturnValue({
enabled: true,
preferredSurface: "origin",
supportsOriginSurface: true,
supportsApproverDmSurface: false,
notifyOriginWhenDmOnly: false,
}),
resolveOriginTarget: vi.fn().mockReturnValue({ to: "origin-chat" }),
},
nativeRuntime: {
availability: {
isConfigured: vi.fn().mockReturnValue(true),
shouldHandle: vi.fn().mockReturnValue(true),
},
presentation: {
buildPendingPayload: vi.fn().mockResolvedValue({ text: "pending" }),
buildResolvedResult: vi.fn(),
buildExpiredResult: vi.fn(),
},
transport: {
prepareTarget: vi.fn().mockResolvedValue({
dedupeKey: "origin-chat",
target: { to: "origin-chat" },
}),
deliverPending: vi
.fn()
.mockResolvedValueOnce({ messageId: "1" })
.mockResolvedValueOnce({ messageId: "2" }),
},
interactions: {
bindPending: vi
.fn()
.mockResolvedValueOnce({ bindingId: "bound-1" })
.mockResolvedValueOnce({ bindingId: "bound-2" }),
unbindPending,
},
},
},
label: "test/approval-handler",
clientDisplayName: "Test Approval Handler",
channel: "test",
channelLabel: "Test",
cfg: { channels: {} } as never,
});
const request = {
id: "exec:stop-1",
expiresAtMs: Date.now() + 60_000,
request: {
command: "echo hi",
turnSourceChannel: "test",
turnSourceTo: "origin-chat",
},
} as never;
await runtime?.handleRequested(request);
await runtime?.handleRequested({
...request,
id: "exec:stop-2",
});
await expect(runtime?.stop()).resolves.toBeUndefined();
expect(unbindPending).toHaveBeenCalledTimes(2);
await expect(runtime?.stop()).resolves.toBeUndefined();
expect(unbindPending).toHaveBeenCalledTimes(2);
});
});
describe("createLazyChannelApprovalNativeRuntimeAdapter", () => {
it("loads the runtime lazily and reuses the loaded adapter", async () => {
const explicitIsConfigured = vi.fn().mockReturnValue(true);
const explicitShouldHandle = vi.fn().mockReturnValue(false);
const buildPendingPayload = vi.fn().mockResolvedValue({ text: "pending" });
const load = vi.fn().mockResolvedValue({
availability: {
isConfigured: vi.fn(),
shouldHandle: vi.fn(),
},
presentation: {
buildPendingPayload,
buildResolvedResult: vi.fn(),
buildExpiredResult: vi.fn(),
},
transport: {
prepareTarget: vi.fn(),
deliverPending: vi.fn(),
},
});
const adapter = createLazyChannelApprovalNativeRuntimeAdapter({
eventKinds: ["exec"],
isConfigured: explicitIsConfigured,
shouldHandle: explicitShouldHandle,
load,
});
const cfg = { channels: {} } as never;
const request = { id: "exec:1" } as never;
const view = {} as never;
expect(adapter.eventKinds).toEqual(["exec"]);
expect(adapter.availability.isConfigured({ cfg })).toBe(true);
expect(adapter.availability.shouldHandle({ cfg, request })).toBe(false);
await expect(
adapter.presentation.buildPendingPayload({
cfg,
request,
approvalKind: "exec",
nowMs: 1,
view,
}),
).resolves.toEqual({ text: "pending" });
expect(load).toHaveBeenCalledTimes(1);
expect(explicitIsConfigured).toHaveBeenCalledWith({ cfg });
expect(explicitShouldHandle).toHaveBeenCalledWith({ cfg, request });
expect(buildPendingPayload).toHaveBeenCalledWith({
cfg,
request,
approvalKind: "exec",
nowMs: 1,
view,
});
});
it("keeps observe hooks synchronous and only uses the already-loaded runtime", async () => {
const onDelivered = vi.fn();
const load = vi.fn().mockResolvedValue({
availability: {
isConfigured: vi.fn(),
shouldHandle: vi.fn(),
},
presentation: {
buildPendingPayload: vi.fn().mockResolvedValue({ text: "pending" }),
buildResolvedResult: vi.fn(),
buildExpiredResult: vi.fn(),
},
transport: {
prepareTarget: vi.fn(),
deliverPending: vi.fn(),
},
observe: {
onDelivered,
},
});
const adapter = createLazyChannelApprovalNativeRuntimeAdapter({
isConfigured: vi.fn().mockReturnValue(true),
shouldHandle: vi.fn().mockReturnValue(true),
load,
});
adapter.observe?.onDelivered?.({ request: { id: "exec:1" } } as never);
expect(load).not.toHaveBeenCalled();
expect(onDelivered).not.toHaveBeenCalled();
await adapter.presentation.buildPendingPayload({
cfg: {} as never,
request: { id: "exec:1" } as never,
approvalKind: "exec",
nowMs: 1,
view: {} as never,
});
expect(load).toHaveBeenCalledTimes(1);
adapter.observe?.onDelivered?.({ request: { id: "exec:1" } } as never);
expect(onDelivered).toHaveBeenCalledWith({ request: { id: "exec:1" } });
expect(load).toHaveBeenCalledTimes(1);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ import type {
ChannelApprovalNativeTarget,
} from "../channels/plugins/types.adapters.js";
import type { OpenClawConfig } from "../config/config.js";
import { buildChannelApprovalNativeTargetKey } from "./approval-native-target-key.js";
import type { ExecApprovalRequest } from "./exec-approvals.js";
import type { PluginApprovalRequest } from "./plugin-approvals.js";
@@ -22,17 +23,13 @@ export type ChannelApprovalNativeDeliveryPlan = {
notifyOriginWhenDmOnly: boolean;
};
function buildTargetKey(target: ChannelApprovalNativeTarget): string {
return `${target.to}:${target.threadId ?? ""}`;
}
function dedupeTargets(
targets: ChannelApprovalNativePlannedTarget[],
): ChannelApprovalNativePlannedTarget[] {
const seen = new Set<string>();
const deduped: ChannelApprovalNativePlannedTarget[] = [];
for (const target of targets) {
const key = buildTargetKey(target.target);
const key = buildChannelApprovalNativeTargetKey(target.target);
if (seen.has(key)) {
continue;
}

View File

@@ -0,0 +1,212 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
clearApprovalNativeRouteStateForTest,
createApprovalNativeRouteReporter,
} from "./approval-native-route-coordinator.js";
afterEach(() => {
clearApprovalNativeRouteStateForTest();
});
function createGatewayRequestMock() {
return vi.fn(async <T = unknown>() => ({ ok: true }) as T);
}
describe("createApprovalNativeRouteReporter", () => {
it("caps route-notice cleanup timers to five minutes", () => {
vi.useFakeTimers();
try {
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
const requestGateway = createGatewayRequestMock();
const reporter = createApprovalNativeRouteReporter({
handledKinds: new Set(["exec"]),
channel: "slack",
channelLabel: "Slack",
accountId: "default",
requestGateway,
});
reporter.start();
reporter.observeRequest({
approvalKind: "exec",
request: {
id: "approval-long",
request: {
command: "echo hi",
turnSourceChannel: "slack",
turnSourceTo: "channel:C123",
},
createdAtMs: 0,
expiresAtMs: Date.now() + 24 * 60 * 60_000,
},
});
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 5 * 60_000);
} finally {
vi.useRealTimers();
}
});
it("does not wait on runtimes that start after a request was already observed", async () => {
const requestGateway = createGatewayRequestMock();
const lateRuntimeGateway = createGatewayRequestMock();
const request = {
id: "approval-1",
request: {
command: "echo hi",
turnSourceChannel: "slack",
turnSourceTo: "channel:C123",
turnSourceAccountId: "default",
turnSourceThreadId: "1712345678.123456",
},
createdAtMs: 0,
expiresAtMs: Date.now() + 60_000,
} as const;
const reporter = createApprovalNativeRouteReporter({
handledKinds: new Set(["exec"]),
channel: "slack",
channelLabel: "Slack",
accountId: "default",
requestGateway,
});
reporter.start();
reporter.observeRequest({
approvalKind: "exec",
request,
});
const lateReporter = createApprovalNativeRouteReporter({
handledKinds: new Set(["exec"]),
channel: "slack",
channelLabel: "Slack",
accountId: "default",
requestGateway: lateRuntimeGateway,
});
lateReporter.start();
await reporter.reportDelivery({
approvalKind: "exec",
request,
deliveryPlan: {
targets: [],
originTarget: {
to: "channel:C123",
threadId: "1712345678.123456",
},
notifyOriginWhenDmOnly: true,
},
deliveredTargets: [
{
surface: "approver-dm",
target: {
to: "user:owner",
},
reason: "preferred",
},
],
});
expect(requestGateway).toHaveBeenCalledWith("send", {
channel: "slack",
to: "channel:C123",
accountId: "default",
threadId: "1712345678.123456",
message: "Approval required. I sent the approval request to Slack DMs, not this chat.",
idempotencyKey: "approval-route-notice:approval-1",
});
expect(lateRuntimeGateway).not.toHaveBeenCalled();
});
it("does not suppress the notice when another account delivered to the same target id", async () => {
const originGateway = createGatewayRequestMock();
const otherGateway = createGatewayRequestMock();
const request = {
id: "approval-2",
request: {
command: "echo hi",
turnSourceChannel: "slack",
turnSourceTo: "channel:C123",
},
createdAtMs: 0,
expiresAtMs: Date.now() + 60_000,
} as const;
const originReporter = createApprovalNativeRouteReporter({
handledKinds: new Set(["exec"]),
channel: "slack",
channelLabel: "Slack",
accountId: "work-a",
requestGateway: originGateway,
});
const otherReporter = createApprovalNativeRouteReporter({
handledKinds: new Set(["exec"]),
channel: "slack",
channelLabel: "Slack",
accountId: "work-b",
requestGateway: otherGateway,
});
originReporter.start();
otherReporter.start();
originReporter.observeRequest({
approvalKind: "exec",
request,
});
otherReporter.observeRequest({
approvalKind: "exec",
request,
});
await originReporter.reportDelivery({
approvalKind: "exec",
request,
deliveryPlan: {
targets: [],
originTarget: {
to: "channel:C123",
},
notifyOriginWhenDmOnly: true,
},
deliveredTargets: [
{
surface: "approver-dm",
target: {
to: "user:owner-a",
},
reason: "preferred",
},
],
});
await otherReporter.reportDelivery({
approvalKind: "exec",
request,
deliveryPlan: {
targets: [],
originTarget: {
to: "channel:C123",
},
notifyOriginWhenDmOnly: true,
},
deliveredTargets: [
{
surface: "origin",
target: {
to: "channel:C123",
},
reason: "fallback",
},
],
});
expect(originGateway).toHaveBeenCalledWith("send", {
channel: "slack",
to: "channel:C123",
accountId: "work-a",
threadId: undefined,
message: "Approval required. I sent the approval request to Slack DMs, not this chat.",
idempotencyKey: "approval-route-notice:approval-2",
});
expect(otherGateway).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,392 @@
import type { ChannelApprovalKind } from "../channels/plugins/types.adapters.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import type {
ChannelApprovalNativeDeliveryPlan,
ChannelApprovalNativePlannedTarget,
} from "./approval-native-delivery.js";
import {
describeApprovalDeliveryDestination,
resolveApprovalRoutedElsewhereNoticeText,
} from "./approval-native-route-notice.js";
import { buildChannelApprovalNativeTargetKey } from "./approval-native-target-key.js";
import type { ExecApprovalRequest } from "./exec-approvals.js";
import type { PluginApprovalRequest } from "./plugin-approvals.js";
type GatewayRequestFn = <T = unknown>(
method: string,
params: Record<string, unknown>,
) => Promise<T>;
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type ApprovalRouteRuntimeRecord = {
runtimeId: string;
handledKinds: ReadonlySet<ChannelApprovalKind>;
channel?: string;
channelLabel?: string;
accountId?: string | null;
requestGateway: GatewayRequestFn;
};
type ApprovalRouteReport = {
runtimeId: string;
request: ApprovalRequest;
channel?: string;
channelLabel?: string;
accountId?: string | null;
deliveryPlan: ChannelApprovalNativeDeliveryPlan;
deliveredTargets: readonly ChannelApprovalNativePlannedTarget[];
requestGateway: GatewayRequestFn;
};
type PendingApprovalRouteNotice = {
request: ApprovalRequest;
approvalKind: ChannelApprovalKind;
expectedRuntimeIds: Set<string>;
reports: Map<string, ApprovalRouteReport>;
cleanupTimeout: NodeJS.Timeout | null;
finalized: boolean;
};
type RouteNoticeTarget = {
channel: string;
to: string;
accountId?: string | null;
threadId?: string | number | null;
};
const activeApprovalRouteRuntimes = new Map<string, ApprovalRouteRuntimeRecord>();
const pendingApprovalRouteNotices = new Map<string, PendingApprovalRouteNotice>();
let approvalRouteRuntimeSeq = 0;
const MAX_APPROVAL_ROUTE_NOTICE_TTL_MS = 5 * 60_000;
function normalizeChannel(value?: string | null): string {
return value?.trim().toLowerCase() || "";
}
function clearPendingApprovalRouteNotice(approvalId: string): void {
const entry = pendingApprovalRouteNotices.get(approvalId);
if (!entry) {
return;
}
pendingApprovalRouteNotices.delete(approvalId);
if (entry.cleanupTimeout) {
clearTimeout(entry.cleanupTimeout);
}
}
function createPendingApprovalRouteNotice(params: {
request: ApprovalRequest;
approvalKind: ChannelApprovalKind;
expectedRuntimeIds?: Iterable<string>;
}): PendingApprovalRouteNotice {
const timeoutMs = Math.min(
Math.max(0, params.request.expiresAtMs - Date.now()),
MAX_APPROVAL_ROUTE_NOTICE_TTL_MS,
);
const cleanupTimeout = setTimeout(() => {
clearPendingApprovalRouteNotice(params.request.id);
}, timeoutMs);
cleanupTimeout.unref?.();
return {
request: params.request,
approvalKind: params.approvalKind,
// Snapshot siblings at first observation time so already-running runtimes
// can still aggregate one notice, while late-starting runtimes that cannot
// replay old gateway events never block the quorum.
expectedRuntimeIds: new Set(params.expectedRuntimeIds ?? []),
reports: new Map(),
cleanupTimeout,
finalized: false,
};
}
function resolveRouteNoticeTargetFromRequest(request: ApprovalRequest): RouteNoticeTarget | null {
const channel = request.request.turnSourceChannel?.trim();
const to = request.request.turnSourceTo?.trim();
if (!channel || !to) {
return null;
}
return {
channel,
to,
accountId: request.request.turnSourceAccountId ?? undefined,
threadId: request.request.turnSourceThreadId ?? undefined,
};
}
function resolveFallbackRouteNoticeTarget(report: ApprovalRouteReport): RouteNoticeTarget | null {
const channel = report.channel?.trim();
const to = report.deliveryPlan.originTarget?.to?.trim();
if (!channel || !to) {
return null;
}
return {
channel,
to,
accountId: report.accountId ?? undefined,
threadId: report.deliveryPlan.originTarget?.threadId ?? undefined,
};
}
function didReportDeliverToOrigin(report: ApprovalRouteReport, originAccountId?: string): boolean {
const originTarget = report.deliveryPlan.originTarget;
if (!originTarget) {
return false;
}
const reportAccountId = normalizeOptionalString(report.accountId);
if (
originAccountId !== undefined &&
reportAccountId !== undefined &&
reportAccountId !== originAccountId
) {
return false;
}
const originKey = buildChannelApprovalNativeTargetKey(originTarget);
return report.deliveredTargets.some(
(plannedTarget) => buildChannelApprovalNativeTargetKey(plannedTarget.target) === originKey,
);
}
function resolveApprovalRouteNotice(params: {
request: ApprovalRequest;
reports: readonly ApprovalRouteReport[];
}): { requestGateway: GatewayRequestFn; target: RouteNoticeTarget; text: string } | null {
const explicitTarget = resolveRouteNoticeTargetFromRequest(params.request);
const originChannel = normalizeChannel(
explicitTarget?.channel ?? params.request.request.turnSourceChannel,
);
const fallbackTarget =
params.reports
.filter((report) => normalizeChannel(report.channel) === originChannel || !originChannel)
.map(resolveFallbackRouteNoticeTarget)
.find((target) => target !== null) ?? null;
const target = explicitTarget
? {
...fallbackTarget,
...explicitTarget,
accountId: explicitTarget.accountId ?? fallbackTarget?.accountId,
threadId: explicitTarget.threadId ?? fallbackTarget?.threadId,
}
: fallbackTarget;
if (!target) {
return null;
}
const originAccountId = normalizeOptionalString(target.accountId);
// If any same-channel runtime already delivered into the origin chat, every
// other fallback delivery becomes supplemental and should not trigger a notice.
const originDelivered = params.reports.some((report) => {
if (originChannel && normalizeChannel(report.channel) !== originChannel) {
return false;
}
return didReportDeliverToOrigin(report, originAccountId);
});
if (originDelivered) {
return null;
}
const destinations = params.reports.flatMap((report) => {
if (!report.channelLabel || report.deliveredTargets.length === 0) {
return [];
}
const reportChannel = normalizeChannel(report.channel);
if (
originChannel &&
reportChannel === originChannel &&
!report.deliveryPlan.notifyOriginWhenDmOnly
) {
return [];
}
const reportAccountId = normalizeOptionalString(report.accountId);
if (
originChannel &&
reportChannel === originChannel &&
originAccountId !== undefined &&
reportAccountId !== undefined &&
reportAccountId !== originAccountId
) {
return [];
}
return [
describeApprovalDeliveryDestination({
channelLabel: report.channelLabel,
deliveredTargets: report.deliveredTargets,
}),
];
});
const text = resolveApprovalRoutedElsewhereNoticeText(destinations);
if (!text) {
return null;
}
const requestGateway =
params.reports.find((report) => activeApprovalRouteRuntimes.has(report.runtimeId))
?.requestGateway ?? params.reports[0]?.requestGateway;
if (!requestGateway) {
return null;
}
return {
requestGateway,
target,
text,
};
}
async function maybeFinalizeApprovalRouteNotice(approvalId: string): Promise<void> {
const entry = pendingApprovalRouteNotices.get(approvalId);
if (!entry || entry.finalized) {
return;
}
for (const runtimeId of entry.expectedRuntimeIds) {
if (!entry.reports.has(runtimeId)) {
return;
}
}
entry.finalized = true;
const reports = Array.from(entry.reports.values());
const notice = resolveApprovalRouteNotice({
request: entry.request,
reports,
});
clearPendingApprovalRouteNotice(approvalId);
if (!notice) {
return;
}
try {
await notice.requestGateway("send", {
channel: notice.target.channel,
to: notice.target.to,
accountId: notice.target.accountId ?? undefined,
threadId: notice.target.threadId ?? undefined,
message: notice.text,
idempotencyKey: `approval-route-notice:${approvalId}`,
});
} catch {
// The approval delivery already succeeded; the follow-up notice is best-effort.
}
}
export function createApprovalNativeRouteReporter(params: {
handledKinds: ReadonlySet<ChannelApprovalKind>;
channel?: string;
channelLabel?: string;
accountId?: string | null;
requestGateway: GatewayRequestFn;
}) {
const runtimeId = `native-approval-route:${++approvalRouteRuntimeSeq}`;
let registered = false;
const report = async (payload: {
approvalKind: ChannelApprovalKind;
request: ApprovalRequest;
deliveryPlan: ChannelApprovalNativeDeliveryPlan;
deliveredTargets: readonly ChannelApprovalNativePlannedTarget[];
}): Promise<void> => {
if (!registered || !params.handledKinds.has(payload.approvalKind)) {
return;
}
const entry =
pendingApprovalRouteNotices.get(payload.request.id) ??
createPendingApprovalRouteNotice({
request: payload.request,
approvalKind: payload.approvalKind,
expectedRuntimeIds: [runtimeId],
});
entry.expectedRuntimeIds.add(runtimeId);
entry.reports.set(runtimeId, {
runtimeId,
request: payload.request,
channel: params.channel,
channelLabel: params.channelLabel,
accountId: params.accountId,
deliveryPlan: payload.deliveryPlan,
deliveredTargets: payload.deliveredTargets,
requestGateway: params.requestGateway,
});
pendingApprovalRouteNotices.set(payload.request.id, entry);
await maybeFinalizeApprovalRouteNotice(payload.request.id);
};
return {
observeRequest(payload: { approvalKind: ChannelApprovalKind; request: ApprovalRequest }): void {
if (!registered || !params.handledKinds.has(payload.approvalKind)) {
return;
}
const entry =
pendingApprovalRouteNotices.get(payload.request.id) ??
createPendingApprovalRouteNotice({
request: payload.request,
approvalKind: payload.approvalKind,
expectedRuntimeIds: Array.from(activeApprovalRouteRuntimes.values())
.filter((runtime) => runtime.handledKinds.has(payload.approvalKind))
.map((runtime) => runtime.runtimeId),
});
entry.expectedRuntimeIds.add(runtimeId);
pendingApprovalRouteNotices.set(payload.request.id, entry);
},
start(): void {
if (registered) {
return;
}
activeApprovalRouteRuntimes.set(runtimeId, {
runtimeId,
handledKinds: params.handledKinds,
channel: params.channel,
channelLabel: params.channelLabel,
accountId: params.accountId,
requestGateway: params.requestGateway,
});
registered = true;
},
async reportSkipped(params: {
approvalKind: ChannelApprovalKind;
request: ApprovalRequest;
}): Promise<void> {
await report({
approvalKind: params.approvalKind,
request: params.request,
deliveryPlan: {
targets: [],
originTarget: null,
notifyOriginWhenDmOnly: false,
},
deliveredTargets: [],
});
},
async reportDelivery(params: {
approvalKind: ChannelApprovalKind;
request: ApprovalRequest;
deliveryPlan: ChannelApprovalNativeDeliveryPlan;
deliveredTargets: readonly ChannelApprovalNativePlannedTarget[];
}): Promise<void> {
await report(params);
},
async stop(): Promise<void> {
if (!registered) {
return;
}
registered = false;
activeApprovalRouteRuntimes.delete(runtimeId);
for (const entry of pendingApprovalRouteNotices.values()) {
entry.expectedRuntimeIds.delete(runtimeId);
if (entry.expectedRuntimeIds.size === 0) {
clearPendingApprovalRouteNotice(entry.request.id);
continue;
}
await maybeFinalizeApprovalRouteNotice(entry.request.id);
}
},
};
}
export function clearApprovalNativeRouteStateForTest(): void {
for (const approvalId of Array.from(pendingApprovalRouteNotices.keys())) {
clearPendingApprovalRouteNotice(approvalId);
}
activeApprovalRouteRuntimes.clear();
approvalRouteRuntimeSeq = 0;
}

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import {
describeApprovalDeliveryDestination,
resolveApprovalRoutedElsewhereNoticeText,
} from "./approval-native-route-notice.js";
describe("describeApprovalDeliveryDestination", () => {
it("labels approver-DM-only delivery as channel DMs", () => {
expect(
describeApprovalDeliveryDestination({
channelLabel: "Telegram",
deliveredTargets: [
{
surface: "approver-dm",
target: { to: "111" },
reason: "fallback",
},
],
}),
).toBe("Telegram DMs");
});
it("labels mixed-surface delivery as the channel itself", () => {
expect(
describeApprovalDeliveryDestination({
channelLabel: "Matrix",
deliveredTargets: [
{
surface: "origin",
target: { to: "room:!abc:example.com" },
reason: "preferred",
},
],
}),
).toBe("Matrix");
});
});
describe("resolveApprovalRoutedElsewhereNoticeText", () => {
it("reports sorted unique destinations", () => {
expect(
resolveApprovalRoutedElsewhereNoticeText(["Telegram DMs", "Matrix DMs", "Telegram DMs"]),
).toBe(
"Approval required. I sent the approval request to Matrix DMs or Telegram DMs, not this chat.",
);
});
it("suppresses the notice when there are no destinations", () => {
expect(resolveApprovalRoutedElsewhereNoticeText([])).toBeNull();
});
});

Some files were not shown because too many files have changed in this diff Show More