mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 01:31:08 +00:00
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:
committed by
GitHub
parent
4108901932
commit
d78512b09d
@@ -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 Mistral’s 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
45
extensions/discord/src/approval-handler.runtime.test.ts
Normal file
45
extensions/discord/src/approval-handler.runtime.test.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
626
extensions/discord/src/approval-handler.runtime.ts
Normal file
626
extensions/discord/src/approval-handler.runtime.ts
Normal 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)}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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"), () => ({
|
||||
|
||||
@@ -635,7 +635,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
|
||||
},
|
||||
}),
|
||||
},
|
||||
auth: feishuApprovalAuth,
|
||||
approvalCapability: feishuApprovalAuth,
|
||||
secrets: {
|
||||
secretTargetRegistryEntries,
|
||||
collectRuntimeConfigAssignments,
|
||||
|
||||
@@ -127,7 +127,7 @@ export const googlechatPlugin = createChatChannelPlugin({
|
||||
},
|
||||
}),
|
||||
},
|
||||
auth: googleChatApprovalAuth,
|
||||
approvalCapability: googleChatApprovalAuth,
|
||||
secrets: {
|
||||
secretTargetRegistryEntries,
|
||||
collectRuntimeConfigAssignments,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
46
extensions/matrix/src/approval-handler.runtime.test.ts
Normal file
46
extensions/matrix/src/approval-handler.runtime.test.ts
Normal 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"),
|
||||
});
|
||||
});
|
||||
});
|
||||
393
extensions/matrix/src/approval-handler.runtime.ts
Normal file
393
extensions/matrix/src/approval-handler.runtime.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
});
|
||||
6
extensions/matrix/src/approval-ids.ts
Normal file
6
extensions/matrix/src/approval-ids.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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\./,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -292,7 +292,7 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
|
||||
isConfigured: isMattermostConfigured,
|
||||
describeAccount: describeMattermostAccount,
|
||||
},
|
||||
auth: mattermostApprovalAuth,
|
||||
approvalCapability: mattermostApprovalAuth,
|
||||
doctor: mattermostDoctor,
|
||||
groups: {
|
||||
resolveRequireMention: resolveMattermostGroupRequireMention,
|
||||
|
||||
@@ -149,7 +149,7 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
|
||||
configured: account.configured,
|
||||
}),
|
||||
},
|
||||
auth: msTeamsApprovalAuth,
|
||||
approvalCapability: msTeamsApprovalAuth,
|
||||
doctor: {
|
||||
dmAllowFromMode: "topOnly",
|
||||
groupModel: "hybrid",
|
||||
|
||||
@@ -94,7 +94,7 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
|
||||
},
|
||||
}),
|
||||
},
|
||||
auth: nextcloudTalkApprovalAuth,
|
||||
approvalCapability: nextcloudTalkApprovalAuth,
|
||||
doctor: nextcloudTalkDoctor,
|
||||
groups: {
|
||||
resolveRequireMention: ({ cfg, accountId, groupId }) => {
|
||||
|
||||
@@ -249,7 +249,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount, SignalProbe> =
|
||||
setup: signalSetupAdapter,
|
||||
}),
|
||||
actions: signalMessageActions,
|
||||
auth: signalApprovalAuth,
|
||||
approvalCapability: signalApprovalAuth,
|
||||
allowlist: buildDmGroupAccountAllowlistAdapter({
|
||||
channelId: "signal",
|
||||
resolveAccount: resolveSignalAccount,
|
||||
|
||||
331
extensions/slack/src/approval-handler.runtime.ts
Normal file
331
extensions/slack/src/approval-handler.runtime.ts
Normal 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)}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -227,7 +227,7 @@ export function createSynologyChatPlugin(): SynologyChatPlugin {
|
||||
config: {
|
||||
...synologyChatConfigAdapter,
|
||||
},
|
||||
auth: synologyChatApprovalAuth,
|
||||
approvalCapability: synologyChatApprovalAuth,
|
||||
messaging: {
|
||||
normalizeTarget: (target: string) => {
|
||||
const trimmed = target.trim();
|
||||
|
||||
188
extensions/telegram/src/approval-handler.runtime.ts
Normal file
188
extensions/telegram/src/approval-handler.runtime.ts
Normal 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)}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"})`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export { TelegramExecApprovalHandler } from "./exec-approvals-handler.js";
|
||||
export { startTelegramWebhook } from "./webhook.js";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -146,8 +146,8 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> =
|
||||
toolContext,
|
||||
}),
|
||||
},
|
||||
approvalCapability: whatsappApprovalAuth,
|
||||
auth: {
|
||||
...whatsappApprovalAuth,
|
||||
login: async ({ cfg, accountId, runtime, verbose }) => {
|
||||
const resolvedAccountId =
|
||||
accountId?.trim() ||
|
||||
|
||||
@@ -186,7 +186,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount, ZaloProbeResult> =
|
||||
},
|
||||
}),
|
||||
},
|
||||
auth: zaloApprovalAuth,
|
||||
approvalCapability: zaloApprovalAuth,
|
||||
secrets: {
|
||||
secretTargetRegistryEntries,
|
||||
collectRuntimeConfigAssignments,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -57,6 +57,7 @@ function createLifecycleContext(params: {
|
||||
pendingMessagingTargets: new Map(),
|
||||
successfulCronAdds: 0,
|
||||
pendingMessagingMediaUrls: new Map(),
|
||||
deterministicApprovalPromptPending: false,
|
||||
deterministicApprovalPromptSent: false,
|
||||
} as never,
|
||||
log: {
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -35,6 +35,7 @@ function createMockContext(overrides?: {
|
||||
messagingToolSentTextsNormalized: [],
|
||||
messagingToolSentMediaUrls: [],
|
||||
messagingToolSentTargets: [],
|
||||
deterministicApprovalPromptPending: false,
|
||||
deterministicApprovalPromptSent: false,
|
||||
},
|
||||
log: { debug: vi.fn(), warn: vi.fn() },
|
||||
|
||||
@@ -47,6 +47,7 @@ function createTestContext(): {
|
||||
pendingMessagingMediaUrls: new Map<string, string[]>(),
|
||||
pendingToolMediaUrls: [],
|
||||
pendingToolAudioAsVoice: false,
|
||||
deterministicApprovalPromptPending: false,
|
||||
messagingToolSentTexts: [],
|
||||
messagingToolSentTextsNormalized: [],
|
||||
messagingToolSentMediaUrls: [],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }
|
||||
|
||||
41
src/auto-reply/reply/get-reply-run.exec-hint.test.ts
Normal file
41
src/auto-reply/reply/get-reply-run.exec-hint.test.ts
Normal 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.");
|
||||
});
|
||||
});
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
213
src/gateway/server-channels.approval-bootstrap.test.ts
Normal file
213
src/gateway/server-channels.approval-bootstrap.test.ts
Normal 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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
143
src/infra/approval-gateway-resolver.test.ts
Normal file
143
src/infra/approval-gateway-resolver.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
79
src/infra/approval-gateway-resolver.ts
Normal file
79
src/infra/approval-gateway-resolver.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
406
src/infra/approval-handler-bootstrap.test.ts
Normal file
406
src/infra/approval-handler-bootstrap.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
167
src/infra/approval-handler-bootstrap.ts
Normal file
167
src/infra/approval-handler-bootstrap.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
462
src/infra/approval-handler-runtime.test.ts
Normal file
462
src/infra/approval-handler-runtime.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
1072
src/infra/approval-handler-runtime.ts
Normal file
1072
src/infra/approval-handler-runtime.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
212
src/infra/approval-native-route-coordinator.test.ts
Normal file
212
src/infra/approval-native-route-coordinator.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
392
src/infra/approval-native-route-coordinator.ts
Normal file
392
src/infra/approval-native-route-coordinator.ts
Normal 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;
|
||||
}
|
||||
51
src/infra/approval-native-route-notice.test.ts
Normal file
51
src/infra/approval-native-route-notice.test.ts
Normal 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
Reference in New Issue
Block a user