mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
Revert "feat: expose plugin approval action metadata" (#87419)
This reverts commit 0c867eef75.
# Conflicts:
# docs/.generated/plugin-sdk-api-baseline.sha256
This commit is contained in:
@@ -5952,7 +5952,6 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
|
||||
public let toolname: String?
|
||||
public let toolcallid: String?
|
||||
public let alloweddecisions: [String]?
|
||||
public let actions: [[String: AnyCodable]]?
|
||||
public let agentid: String?
|
||||
public let sessionkey: String?
|
||||
public let turnsourcechannel: String?
|
||||
@@ -5961,7 +5960,6 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
|
||||
public let turnsourcethreadid: AnyCodable?
|
||||
public let timeoutms: Int?
|
||||
public let twophase: Bool?
|
||||
public let keeppendingwithoutroute: Bool?
|
||||
|
||||
public init(
|
||||
pluginid: String?,
|
||||
@@ -5971,7 +5969,6 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
|
||||
toolname: String?,
|
||||
toolcallid: String?,
|
||||
alloweddecisions: [String]?,
|
||||
actions: [[String: AnyCodable]]?,
|
||||
agentid: String?,
|
||||
sessionkey: String?,
|
||||
turnsourcechannel: String?,
|
||||
@@ -5979,8 +5976,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
|
||||
turnsourceaccountid: String?,
|
||||
turnsourcethreadid: AnyCodable?,
|
||||
timeoutms: Int?,
|
||||
twophase: Bool?,
|
||||
keeppendingwithoutroute: Bool?)
|
||||
twophase: Bool?)
|
||||
{
|
||||
self.pluginid = pluginid
|
||||
self.title = title
|
||||
@@ -5989,7 +5985,6 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
|
||||
self.toolname = toolname
|
||||
self.toolcallid = toolcallid
|
||||
self.alloweddecisions = alloweddecisions
|
||||
self.actions = actions
|
||||
self.agentid = agentid
|
||||
self.sessionkey = sessionkey
|
||||
self.turnsourcechannel = turnsourcechannel
|
||||
@@ -5998,7 +5993,6 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
|
||||
self.turnsourcethreadid = turnsourcethreadid
|
||||
self.timeoutms = timeoutms
|
||||
self.twophase = twophase
|
||||
self.keeppendingwithoutroute = keeppendingwithoutroute
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -6009,7 +6003,6 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
|
||||
case toolname = "toolName"
|
||||
case toolcallid = "toolCallId"
|
||||
case alloweddecisions = "allowedDecisions"
|
||||
case actions
|
||||
case agentid = "agentId"
|
||||
case sessionkey = "sessionKey"
|
||||
case turnsourcechannel = "turnSourceChannel"
|
||||
@@ -6018,7 +6011,6 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
|
||||
case turnsourcethreadid = "turnSourceThreadId"
|
||||
case timeoutms = "timeoutMs"
|
||||
case twophase = "twoPhase"
|
||||
case keeppendingwithoutroute = "keepPendingWithoutRoute"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
f83d097e726867b49d4e9973ae0c3b26fe78deee2e626bee3e2d427ad3340ab2 plugin-sdk-api-baseline.json
|
||||
e439dbeee85934ac21ece1d3006f7c6b46236deaafc6458e9a2d3622f283940f plugin-sdk-api-baseline.jsonl
|
||||
eaa8ebefa21504cda1ece595b2f6364267434c23995f08d09d2fb998c5600539 plugin-sdk-api-baseline.json
|
||||
9892152bbf723daac7f785f3a4eef66b22b85707e8b85a170f6f5c73116eaca5 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -183,81 +183,12 @@ local proof.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Plugin capabilities
|
||||
<a id="registering-agent-tools"></a>
|
||||
|
||||
A single plugin can register any number of capabilities via the `api` object:
|
||||
## Registering tools
|
||||
|
||||
| Capability | Registration method | Detailed guide |
|
||||
| ---------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------- |
|
||||
| Text inference (LLM) | `api.registerProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins) |
|
||||
| CLI inference backend | `api.registerCliBackend(...)` | [CLI Backend Plugins](/plugins/cli-backend-plugins) |
|
||||
| Channel / messaging | `api.registerChannel(...)` | [Channel Plugins](/plugins/sdk-channel-plugins) |
|
||||
| Speech (TTS/STT) | `api.registerSpeechProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
|
||||
| Realtime transcription | `api.registerRealtimeTranscriptionProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
|
||||
| Realtime voice | `api.registerRealtimeVoiceProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
|
||||
| Media understanding | `api.registerMediaUnderstandingProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
|
||||
| Image generation | `api.registerImageGenerationProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
|
||||
| Music generation | `api.registerMusicGenerationProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
|
||||
| Video generation | `api.registerVideoGenerationProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
|
||||
| Web fetch | `api.registerWebFetchProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
|
||||
| Web search | `api.registerWebSearchProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
|
||||
| Tool-result middleware | `api.registerAgentToolResultMiddleware(...)` | [SDK Overview](/plugins/sdk-overview#registration-api) |
|
||||
| Agent tools | `api.registerTool(...)` | Below |
|
||||
| Custom commands | `api.registerCommand(...)` | [Entry Points](/plugins/sdk-entrypoints) |
|
||||
| Plugin hooks | `api.on(...)` | [Plugin hooks](/plugins/hooks) |
|
||||
| Internal event hooks | `api.registerHook(...)` | [Entry Points](/plugins/sdk-entrypoints) |
|
||||
| HTTP routes | `api.registerHttpRoute(...)` | [Internals](/plugins/architecture-internals#gateway-http-routes) |
|
||||
| CLI subcommands | `api.registerCli(...)` | [Entry Points](/plugins/sdk-entrypoints) |
|
||||
|
||||
For the full registration API, see [SDK Overview](/plugins/sdk-overview#registration-api).
|
||||
|
||||
Bundled plugins can use `api.registerAgentToolResultMiddleware(...)` when they
|
||||
need async tool-result rewriting before the model sees the output. Declare the
|
||||
targeted runtimes in `contracts.agentToolResultMiddleware`, for example
|
||||
`["pi", "codex"]`. This is a trusted bundled-plugin seam; external
|
||||
plugins should prefer regular OpenClaw plugin hooks unless OpenClaw grows an
|
||||
explicit trust policy for this capability.
|
||||
|
||||
If your plugin registers custom gateway RPC methods, keep them on a
|
||||
plugin-specific prefix. Core admin namespaces (`config.*`,
|
||||
`exec.approvals.*`, `wizard.*`, `update.*`) stay reserved and always resolve to
|
||||
`operator.admin`, even if a plugin asks for a narrower scope.
|
||||
|
||||
`openclaw/plugin-sdk/gateway-method-runtime` is a reserved control-plane bridge
|
||||
for plugin HTTP routes that declare
|
||||
`contracts.gatewayMethodDispatch: ["authenticated-request"]`. It is an
|
||||
intentional-use guard for reviewed native plugins, not a sandbox boundary.
|
||||
|
||||
Hook guard semantics to keep in mind:
|
||||
|
||||
- `before_tool_call`: `{ block: true }` is terminal and stops lower-priority handlers.
|
||||
- `before_tool_call`: `{ block: false }` is treated as no decision.
|
||||
- `before_tool_call`: `{ requireApproval: { ... } }` pauses agent execution and prompts the user for approval via the exec approval overlay, native channel approval clients, or the `/approve` command on any channel.
|
||||
- `before_install`: `{ block: true }` is terminal and stops lower-priority handlers.
|
||||
- `before_install`: `{ block: false }` is treated as no decision.
|
||||
- `message_sending`: `{ cancel: true }` is terminal and stops lower-priority handlers.
|
||||
- `message_sending`: `{ cancel: false }` is treated as no decision.
|
||||
- `message_received`: prefer the typed `threadId` field when you need inbound thread/topic routing. Keep `metadata` for channel-specific extras.
|
||||
- `message_sending`: prefer typed `replyToId` / `threadId` routing fields over channel-specific metadata keys.
|
||||
|
||||
The `/approve` command handles both exec and plugin approvals with bounded fallback: when an exec approval id is not found, OpenClaw retries the same id through plugin approvals. Plugin approval forwarding can be configured independently via `approvals.plugin` in config.
|
||||
|
||||
If custom approval plumbing needs to detect that same bounded fallback case,
|
||||
prefer `isApprovalNotFoundError` from `openclaw/plugin-sdk/error-runtime`
|
||||
instead of matching approval-expiry strings manually.
|
||||
|
||||
See [Plugin hooks](/plugins/hooks) for examples and the hook reference.
|
||||
|
||||
## Registering agent tools
|
||||
|
||||
Tools are typed functions the LLM can call. They can be required (always
|
||||
available) or optional (user opt-in):
|
||||
|
||||
For simple plugins that only own a fixed set of tools, prefer
|
||||
[`defineToolPlugin`](/plugins/tool-plugins). It generates manifest metadata and
|
||||
keeps `contracts.tools` aligned. Use the lower-level `api.registerTool(...)`
|
||||
surface when the plugin also owns channels, providers, hooks, services,
|
||||
commands, or fully dynamic tool registration.
|
||||
Tools can be required or optional. Required tools are always available when the
|
||||
plugin is enabled. Optional tools require user opt-in.
|
||||
|
||||
```typescript
|
||||
register(api) {
|
||||
|
||||
@@ -196,22 +196,6 @@ type BeforeToolCallResult = {
|
||||
timeoutMs?: number;
|
||||
timeoutBehavior?: "allow" | "deny";
|
||||
allowedDecisions?: Array<"allow-once" | "allow-always" | "deny">;
|
||||
actions?: Array<
|
||||
| {
|
||||
kind: "decision";
|
||||
label: string;
|
||||
style: "primary" | "secondary" | "success" | "danger";
|
||||
decision: "allow-once" | "allow-always" | "deny";
|
||||
commandTemplate: string;
|
||||
}
|
||||
| {
|
||||
kind: "command";
|
||||
label: string;
|
||||
style: "primary" | "secondary" | "success" | "danger";
|
||||
commandTemplate: string;
|
||||
}
|
||||
>;
|
||||
keepPendingWithoutRoute?: boolean;
|
||||
pluginId?: string;
|
||||
onResolution?: (
|
||||
decision: "allow-once" | "allow-always" | "deny" | "timeout" | "cancelled",
|
||||
@@ -227,21 +211,6 @@ Hook guard behavior for typed lifecycle hooks:
|
||||
- `params` rewrites the tool parameters for execution.
|
||||
- `requireApproval` pauses the agent run and asks the user through plugin
|
||||
approvals. The `/approve` command can approve both exec and plugin approvals.
|
||||
- `allowedDecisions` limits the built-in `/approve` decisions. Omit it to offer
|
||||
`allow-once`, `allow-always`, and `deny`.
|
||||
- `actions` customizes the commands shown with the approval. OpenClaw replaces
|
||||
`{id}` in each `commandTemplate` with the generated approval id before storing
|
||||
or rendering the request.
|
||||
- Use `kind: "decision"` plus `decision` when the action resolves the approval
|
||||
through OpenClaw's approval system. Native clients can render those actions as
|
||||
buttons with canonical OpenClaw approval callbacks. Decision actions must be
|
||||
included in `allowedDecisions` when that field is present. Use
|
||||
`kind: "command"` for plugin-owned commands that collect more context or
|
||||
verify an external workflow before resolving the approval; native clients
|
||||
should leave those as visible command text.
|
||||
- `keepPendingWithoutRoute: true` keeps the request pending when no approval
|
||||
client or initiating-channel route can receive it. Use this only when your
|
||||
plugin provides another documented command or UI path to resolve the request.
|
||||
- A lower-priority `block: true` can still block after a higher-priority hook
|
||||
requested approval.
|
||||
- `onResolution` receives the resolved approval decision - `allow-once`,
|
||||
|
||||
@@ -1,58 +1,7 @@
|
||||
import type { PendingApprovalView } from "openclaw/plugin-sdk/approval-handler-runtime";
|
||||
import type { PluginApprovalRequest } from "openclaw/plugin-sdk/approval-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { discordApprovalNativeRuntime } from "./approval-handler.runtime.js";
|
||||
|
||||
describe("discordApprovalNativeRuntime", () => {
|
||||
it("renders command-only plugin approval actions as visible text", async () => {
|
||||
const request = {
|
||||
id: "plugin:req-1",
|
||||
request: {
|
||||
title: "World proof required",
|
||||
description: "Verify with World before the tool runs.",
|
||||
pluginId: "agentkit",
|
||||
toolName: "shell.exec",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 1_000,
|
||||
} satisfies PluginApprovalRequest;
|
||||
const view = {
|
||||
approvalKind: "plugin",
|
||||
approvalId: "plugin:req-1",
|
||||
phase: "pending",
|
||||
title: "World proof required",
|
||||
description: "Verify with World before the tool runs.",
|
||||
metadata: [],
|
||||
severity: "warning",
|
||||
pluginId: "agentkit",
|
||||
toolName: "shell.exec",
|
||||
expiresAtMs: 1_000,
|
||||
actions: [
|
||||
{
|
||||
kind: "command",
|
||||
label: "Verify with World",
|
||||
style: "primary",
|
||||
command: "/agentkit approve plugin:req-1 allow-once",
|
||||
},
|
||||
],
|
||||
} satisfies PendingApprovalView;
|
||||
|
||||
const pending = await discordApprovalNativeRuntime.presentation.buildPendingPayload({
|
||||
cfg: {} as never,
|
||||
accountId: "main",
|
||||
context: {
|
||||
token: "discord-token",
|
||||
config: {} as never,
|
||||
},
|
||||
request,
|
||||
approvalKind: "plugin",
|
||||
nowMs: 0,
|
||||
view,
|
||||
});
|
||||
|
||||
expect(JSON.stringify(pending.body)).toContain("/agentkit approve plugin:req-1 allow-once");
|
||||
});
|
||||
|
||||
it("routes origin approval updates to the Discord thread channel when threadId is present", async () => {
|
||||
const prepared = await discordApprovalNativeRuntime.transport.prepareTarget({
|
||||
cfg: {} as never,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ButtonStyle } from "discord-api-types/v10";
|
||||
import type {
|
||||
ApprovalActionView,
|
||||
ChannelApprovalCapabilityHandlerContext,
|
||||
ExecApprovalExpiredView,
|
||||
ExecApprovalPendingView,
|
||||
@@ -11,6 +10,7 @@ import type {
|
||||
PluginApprovalResolvedView,
|
||||
} from "openclaw/plugin-sdk/approval-handler-runtime";
|
||||
import { createChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
|
||||
import type { ExecApprovalActionDescriptor } from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
import type { ExecApprovalDecision } from "openclaw/plugin-sdk/approval-runtime";
|
||||
import type {
|
||||
DiscordExecApprovalConfig,
|
||||
@@ -47,26 +47,12 @@ type PreparedDeliveryTarget = {
|
||||
discordChannelId: string;
|
||||
recipientUserId?: string;
|
||||
};
|
||||
type DecisionApprovalActionView = ApprovalActionView & {
|
||||
decision: ExecApprovalDecision;
|
||||
};
|
||||
|
||||
export type DiscordApprovalHandlerContext = {
|
||||
token: string;
|
||||
config: DiscordExecApprovalConfig;
|
||||
};
|
||||
|
||||
function isDecisionApprovalAction(
|
||||
action: ApprovalActionView,
|
||||
): action is DecisionApprovalActionView {
|
||||
return (
|
||||
action.kind === "decision" &&
|
||||
(action.decision === "allow-once" ||
|
||||
action.decision === "allow-always" ||
|
||||
action.decision === "deny")
|
||||
);
|
||||
}
|
||||
|
||||
function resolveHandlerContext(params: ChannelApprovalCapabilityHandlerContext): {
|
||||
accountId: string;
|
||||
context: DiscordApprovalHandlerContext;
|
||||
@@ -129,7 +115,7 @@ class ExecApprovalActionButton extends Button {
|
||||
override label: string;
|
||||
override style: ButtonStyle;
|
||||
|
||||
constructor(params: { approvalId: string; descriptor: DecisionApprovalActionView }) {
|
||||
constructor(params: { approvalId: string; descriptor: ExecApprovalActionDescriptor }) {
|
||||
super();
|
||||
this.customId = buildExecApprovalCustomId(params.approvalId, params.descriptor.decision);
|
||||
this.label = params.descriptor.label;
|
||||
@@ -145,7 +131,7 @@ class ExecApprovalActionButton extends Button {
|
||||
}
|
||||
|
||||
class ExecApprovalActionRow extends Row<Button> {
|
||||
constructor(params: { approvalId: string; actions: readonly DecisionApprovalActionView[] }) {
|
||||
constructor(params: { approvalId: string; actions: readonly ExecApprovalActionDescriptor[] }) {
|
||||
super(
|
||||
params.actions.map(
|
||||
(descriptor) => new ExecApprovalActionButton({ approvalId: params.approvalId, descriptor }),
|
||||
@@ -154,38 +140,13 @@ class ExecApprovalActionRow extends Row<Button> {
|
||||
}
|
||||
}
|
||||
|
||||
function createApprovalActionRow(view: PendingApprovalView): Row<Button> | undefined {
|
||||
const actions = view.actions.filter(isDecisionApprovalAction);
|
||||
if (actions.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
function createApprovalActionRow(view: PendingApprovalView): Row<Button> {
|
||||
return new ExecApprovalActionRow({
|
||||
approvalId: view.approvalId,
|
||||
actions,
|
||||
actions: view.actions,
|
||||
});
|
||||
}
|
||||
|
||||
function isCommandOnlyApprovalAction(action: ApprovalActionView): boolean {
|
||||
return (
|
||||
action.kind === "command" &&
|
||||
typeof action.command === "string" &&
|
||||
action.command.trim().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function buildPluginCommandActionLines(actions: readonly ApprovalActionView[]): string[] {
|
||||
const commandActions = actions.filter(isCommandOnlyApprovalAction);
|
||||
if (commandActions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
"### Actions",
|
||||
...commandActions.map(
|
||||
(action) => `- ${action.label}: \`${formatCommandPreview(action.command.trim(), 240)}\``,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function buildApprovalMetadataLines(
|
||||
metadata: readonly { label: string; value: string }[],
|
||||
): string[] {
|
||||
@@ -270,10 +231,7 @@ function createPluginApprovalRequestContainer(params: {
|
||||
description: "A plugin action needs your approval.",
|
||||
commandPreview: formatCommandPreview(params.view.title, 700),
|
||||
commandSecondaryPreview: formatOptionalCommandPreview(params.view.description, 1000),
|
||||
metadataLines: [
|
||||
...buildApprovalMetadataLines(params.view.metadata),
|
||||
...buildPluginCommandActionLines(params.view.actions),
|
||||
],
|
||||
metadataLines: buildApprovalMetadataLines(params.view.metadata),
|
||||
actionRow: params.actionRow,
|
||||
footer: `Expires <t:${expiresAtSeconds}:R> · ID: ${params.view.approvalId}`,
|
||||
accentColor,
|
||||
|
||||
@@ -31,14 +31,12 @@ describe("imessageApprovalNativeRuntime", () => {
|
||||
commandText: "echo hi",
|
||||
actions: [
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "allow-once",
|
||||
label: "Allow Once",
|
||||
command: "/approve exec-1 allow-once",
|
||||
style: "success",
|
||||
},
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "deny",
|
||||
label: "Deny",
|
||||
command: "/approve exec-1 deny",
|
||||
@@ -83,21 +81,18 @@ describe("imessageApprovalNativeRuntime", () => {
|
||||
severity: "warning",
|
||||
actions: [
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "allow-once",
|
||||
label: "Allow Once",
|
||||
command: "/approve plugin:abc allow-once",
|
||||
style: "success",
|
||||
},
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "allow-always",
|
||||
label: "Allow Always",
|
||||
command: "/approve plugin:abc allow-always",
|
||||
style: "primary",
|
||||
},
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "deny",
|
||||
label: "Deny",
|
||||
command: "/approve plugin:abc deny",
|
||||
@@ -115,70 +110,6 @@ describe("imessageApprovalNativeRuntime", () => {
|
||||
expect(payload.allowedDecisions).toEqual(["allow-once", "allow-always", "deny"]);
|
||||
});
|
||||
|
||||
it("keeps plugin command actions visible without registering them as reactions", async () => {
|
||||
const payload = await imessageApprovalNativeRuntime.presentation.buildPendingPayload({
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
context: { accountId: "default" },
|
||||
request: {
|
||||
id: "plugin:agentkit",
|
||||
request: {
|
||||
title: "World proof required for exec",
|
||||
description: "Verify with World before exec runs.",
|
||||
pluginId: "agentkit",
|
||||
toolName: "exec",
|
||||
severity: "warning",
|
||||
actions: [
|
||||
{
|
||||
kind: "command",
|
||||
label: "Verify once",
|
||||
command: "/agentkit approve plugin:agentkit allow-once",
|
||||
style: "success",
|
||||
},
|
||||
{
|
||||
kind: "decision",
|
||||
label: "Deny",
|
||||
command: "/approve plugin:agentkit deny",
|
||||
decision: "deny",
|
||||
style: "danger",
|
||||
},
|
||||
],
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 60_000,
|
||||
},
|
||||
approvalKind: "plugin",
|
||||
nowMs: 0,
|
||||
view: {
|
||||
approvalKind: "plugin",
|
||||
approvalId: "plugin:agentkit",
|
||||
title: "World proof required for exec",
|
||||
severity: "warning",
|
||||
actions: [
|
||||
{
|
||||
kind: "command",
|
||||
label: "Verify once",
|
||||
command: "/agentkit approve plugin:agentkit allow-once",
|
||||
style: "success",
|
||||
},
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "deny",
|
||||
label: "Deny",
|
||||
command: "/approve plugin:agentkit deny",
|
||||
style: "danger",
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(payload.text).toContain("/agentkit approve plugin:agentkit allow-once");
|
||||
expect(payload.text).toContain("/approve plugin:agentkit deny");
|
||||
expect(payload.text).toContain("👎 Deny");
|
||||
expect(payload.text).not.toContain("👍 Allow Once");
|
||||
expect(payload.allowedDecisions).toEqual(["deny"]);
|
||||
});
|
||||
|
||||
it("normalizes iMessage handle targets and carries account ids into prepared delivery", async () => {
|
||||
await expect(
|
||||
imessageApprovalNativeRuntime.transport.prepareTarget({
|
||||
|
||||
@@ -61,9 +61,7 @@ function buildPendingPayload(params: {
|
||||
nowMs: number;
|
||||
view: PendingApprovalView;
|
||||
}): IMessagePendingDelivery {
|
||||
const allowedDecisions = params.view.actions.flatMap((action) =>
|
||||
action.kind === "decision" ? [action.decision] : [],
|
||||
);
|
||||
const allowedDecisions = params.view.actions.map((action) => action.decision);
|
||||
const payload =
|
||||
params.approvalKind === "plugin"
|
||||
? buildPluginApprovalPendingReplyPayload({
|
||||
|
||||
@@ -89,14 +89,12 @@ function buildExecApprovalView(
|
||||
host: "gateway",
|
||||
actions: [
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "allow-once",
|
||||
label: "Allow Once",
|
||||
style: "success",
|
||||
command: "/approve req-1 allow-once",
|
||||
},
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "deny",
|
||||
label: "Deny",
|
||||
style: "danger",
|
||||
@@ -124,7 +122,6 @@ function buildPluginApprovalView(
|
||||
severity: "critical",
|
||||
actions: [
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "allow-once",
|
||||
label: "Allow Once",
|
||||
style: "success",
|
||||
@@ -280,7 +277,6 @@ describe("matrixApprovalNativeRuntime", () => {
|
||||
allowedDecisions: ["allow-once"],
|
||||
actions: [
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "allow-once",
|
||||
label: "Allow Once",
|
||||
style: "success",
|
||||
@@ -298,46 +294,6 @@ describe("matrixApprovalNativeRuntime", () => {
|
||||
expectRecordFields(mockCall(reactMessage)?.[3], { accountId: "default" });
|
||||
});
|
||||
|
||||
it("preserves plugin command actions in Matrix fallback text", async () => {
|
||||
const view = buildPluginApprovalView({
|
||||
actions: [
|
||||
{
|
||||
kind: "command",
|
||||
label: "Verify with World",
|
||||
style: "primary",
|
||||
command: "/agentkit approve plugin:req-1 allow-once",
|
||||
},
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "deny",
|
||||
label: "Deny",
|
||||
style: "danger",
|
||||
command: "/approve plugin:req-1 deny",
|
||||
},
|
||||
],
|
||||
});
|
||||
const pendingPayload = await buildPendingPayload(view);
|
||||
|
||||
expect(pendingPayload.text).toContain("/agentkit approve plugin:req-1 allow-once");
|
||||
expect(pendingPayload.text).toContain("/approve plugin:req-1 deny");
|
||||
expect(pendingPayload.text).not.toContain("/approve <id>");
|
||||
expect(pendingPayload.extraContent[MATRIX_APPROVAL_METADATA_KEY].actions).toEqual([
|
||||
{
|
||||
kind: "command",
|
||||
label: "Verify with World",
|
||||
style: "primary",
|
||||
command: "/agentkit approve plugin:req-1 allow-once",
|
||||
},
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "deny",
|
||||
label: "Deny",
|
||||
style: "danger",
|
||||
command: "/approve plugin:req-1 deny",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("binds Matrix approval reactions before publishing option reactions", async () => {
|
||||
const sendSingleTextMessage = vi.fn().mockResolvedValue({
|
||||
messageId: "$approval",
|
||||
@@ -485,7 +441,6 @@ describe("matrixApprovalNativeRuntime", () => {
|
||||
const view = buildExecApprovalView({
|
||||
actions: [
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "allow-once",
|
||||
label: "Allow Once",
|
||||
style: "success",
|
||||
|
||||
@@ -57,15 +57,11 @@ type PreparedMatrixTarget = {
|
||||
threadId?: string;
|
||||
};
|
||||
type MatrixApprovalMetadataAction = {
|
||||
kind: PendingApprovalView["actions"][number]["kind"];
|
||||
decision?: ExecApprovalReplyDecision;
|
||||
decision: ExecApprovalReplyDecision;
|
||||
label: string;
|
||||
style: PendingApprovalView["actions"][number]["style"];
|
||||
command: string;
|
||||
};
|
||||
type MutablePluginApprovalActions = Array<
|
||||
NonNullable<PluginApprovalRequest["request"]["actions"]>[number]
|
||||
>;
|
||||
type MatrixApprovalMetadataBase = {
|
||||
version: 1;
|
||||
type: "approval.request";
|
||||
@@ -260,8 +256,7 @@ function buildMatrixApprovalMetadata(params: {
|
||||
metadata: params.view.metadata,
|
||||
allowedDecisions: Array.from(params.allowedDecisions),
|
||||
actions: params.view.actions.map((action) => ({
|
||||
kind: action.kind,
|
||||
...(action.kind === "decision" ? { decision: action.decision } : {}),
|
||||
decision: action.decision,
|
||||
label: action.label,
|
||||
style: action.style,
|
||||
command: action.command,
|
||||
@@ -295,52 +290,11 @@ function buildMatrixApprovalMetadata(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function listDecisionActions(view: PendingApprovalView): ExecApprovalReplyDecision[] {
|
||||
return view.actions.flatMap((action) => (action.kind === "decision" ? [action.decision] : []));
|
||||
}
|
||||
|
||||
function listPluginApprovalActionDescriptors(
|
||||
view: PendingApprovalView,
|
||||
): NonNullable<PluginApprovalRequest["request"]["actions"]> | undefined {
|
||||
if (view.approvalKind !== "plugin") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const actions: MutablePluginApprovalActions = [];
|
||||
for (const action of view.actions) {
|
||||
if (
|
||||
(action.kind !== "command" && action.kind !== "decision") ||
|
||||
!action.command.trim() ||
|
||||
!action.label.trim()
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (action.kind === "command") {
|
||||
actions.push({
|
||||
kind: "command",
|
||||
label: action.label,
|
||||
style: action.style,
|
||||
command: action.command,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
actions.push({
|
||||
kind: "decision",
|
||||
label: action.label,
|
||||
style: action.style,
|
||||
command: action.command,
|
||||
decision: action.decision,
|
||||
});
|
||||
}
|
||||
return actions.length > 0 ? actions : undefined;
|
||||
}
|
||||
|
||||
function buildPendingApprovalContent(params: {
|
||||
view: PendingApprovalView;
|
||||
nowMs: number;
|
||||
}): PendingApprovalContent {
|
||||
const allowedDecisions = listDecisionActions(params.view);
|
||||
const pluginActions = listPluginApprovalActionDescriptors(params.view);
|
||||
const allowedDecisions = params.view.actions.map((action) => action.decision);
|
||||
const payload =
|
||||
params.view.approvalKind === "plugin"
|
||||
? buildPluginApprovalPendingReplyPayload({
|
||||
@@ -353,7 +307,6 @@ function buildPendingApprovalContent(params: {
|
||||
toolName: params.view.toolName ?? undefined,
|
||||
pluginId: params.view.pluginId ?? undefined,
|
||||
agentId: params.view.agentId ?? undefined,
|
||||
actions: pluginActions,
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: params.view.expiresAtMs,
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { PendingApprovalView } from "openclaw/plugin-sdk/approval-handler-runtime";
|
||||
import type { PluginApprovalRequest } from "openclaw/plugin-sdk/approval-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { qqbotApprovalNativeRuntime } from "./handler-runtime.js";
|
||||
|
||||
describe("qqbotApprovalNativeRuntime", () => {
|
||||
it("renders command-only plugin approval actions without an empty keyboard", async () => {
|
||||
const request = {
|
||||
id: "plugin:req-1",
|
||||
request: {
|
||||
title: "World proof required",
|
||||
description: "Verify with World before the tool runs.",
|
||||
pluginId: "agentkit",
|
||||
toolName: "shell.exec",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 1_000,
|
||||
} satisfies PluginApprovalRequest;
|
||||
const view = {
|
||||
approvalKind: "plugin",
|
||||
approvalId: "plugin:req-1",
|
||||
phase: "pending",
|
||||
title: "World proof required",
|
||||
description: "Verify with World before the tool runs.",
|
||||
metadata: [],
|
||||
severity: "warning",
|
||||
pluginId: "agentkit",
|
||||
toolName: "shell.exec",
|
||||
expiresAtMs: 1_000,
|
||||
actions: [
|
||||
{
|
||||
kind: "command",
|
||||
label: "Verify with World",
|
||||
style: "primary",
|
||||
command: "/agentkit approve plugin:req-1 allow-once",
|
||||
},
|
||||
],
|
||||
} satisfies PendingApprovalView;
|
||||
|
||||
const pending = (await qqbotApprovalNativeRuntime.presentation.buildPendingPayload({
|
||||
cfg: {} as never,
|
||||
accountId: "main",
|
||||
context: undefined,
|
||||
request,
|
||||
approvalKind: "plugin",
|
||||
nowMs: 0,
|
||||
view,
|
||||
})) as { text: string; keyboard?: unknown };
|
||||
|
||||
expect(pending.text).toContain("/agentkit approve plugin:req-1 allow-once");
|
||||
expect(pending.keyboard).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,6 @@ import { resolveQQBotAccount } from "../config.js";
|
||||
import { getBridgeLogger } from "../logger.js";
|
||||
|
||||
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
|
||||
type ApprovalDecision = NonNullable<Parameters<typeof buildApprovalKeyboard>[1]>[number];
|
||||
|
||||
type QQBotPendingEntry = {
|
||||
messageId?: string;
|
||||
@@ -44,19 +43,11 @@ type QQBotPendingEntry = {
|
||||
|
||||
type QQBotPendingPayload = {
|
||||
text: string;
|
||||
keyboard?: InlineKeyboard;
|
||||
keyboard: InlineKeyboard;
|
||||
};
|
||||
|
||||
function isExecRequest(request: ApprovalRequest): request is ExecApprovalRequest {
|
||||
return "command" in request.request;
|
||||
}
|
||||
|
||||
function listDecisionActions(
|
||||
actions: readonly { kind?: string; decision?: ApprovalDecision }[],
|
||||
): ApprovalDecision[] {
|
||||
return actions.flatMap((action) =>
|
||||
action.kind === "decision" && action.decision ? [action.decision] : [],
|
||||
);
|
||||
return "expiresAtMs" in request;
|
||||
}
|
||||
|
||||
function resolveQQTarget(request: ApprovalRequest): { type: ChatScope; id: string } | null {
|
||||
@@ -140,12 +131,11 @@ const qqbotApprovalRuntimeSpec: ChannelApprovalNativeRuntimeSpec<
|
||||
presentation: {
|
||||
buildPendingPayload: ({ request, view }) => {
|
||||
const req = request as ApprovalRequest;
|
||||
const text = isExecRequest(req)
|
||||
? buildExecApprovalText(req)
|
||||
: buildPluginApprovalText(req, view.actions);
|
||||
const decisionActions = listDecisionActions(view.actions);
|
||||
const keyboard =
|
||||
decisionActions.length > 0 ? buildApprovalKeyboard(req.id, decisionActions) : undefined;
|
||||
const text = isExecRequest(req) ? buildExecApprovalText(req) : buildPluginApprovalText(req);
|
||||
const keyboard = buildApprovalKeyboard(
|
||||
req.id,
|
||||
view.actions.map((action) => action.decision),
|
||||
);
|
||||
getBridgeLogger().debug?.(
|
||||
`[qqbot:approval-runtime] buildPendingPayload requestId=${req.id} kind=${
|
||||
isExecRequest(req) ? "exec" : "plugin"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildApprovalKeyboard, buildPluginApprovalText } from "./index.js";
|
||||
import { buildApprovalKeyboard } from "./index.js";
|
||||
|
||||
describe("buildApprovalKeyboard", () => {
|
||||
it("omits allow-always when the decision is unavailable", () => {
|
||||
@@ -20,29 +20,3 @@ describe("buildApprovalKeyboard", () => {
|
||||
expect(buttons.map((button) => button.id)).toEqual(["allow", "always", "deny"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPluginApprovalText", () => {
|
||||
it("renders command-only plugin approval actions as visible text", () => {
|
||||
const text = buildPluginApprovalText(
|
||||
{
|
||||
id: "plugin:req-1",
|
||||
request: {
|
||||
title: "World proof required",
|
||||
description: "Verify with World before the tool runs.",
|
||||
pluginId: "agentkit",
|
||||
toolName: "shell.exec",
|
||||
},
|
||||
},
|
||||
[
|
||||
{
|
||||
kind: "command",
|
||||
label: "Verify with World",
|
||||
style: "primary",
|
||||
command: "/agentkit approve plugin:req-1 allow-once",
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(text).toContain("/agentkit approve plugin:req-1 allow-once");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,14 +54,6 @@ interface ParsedApprovalAction {
|
||||
decision: ApprovalDecision;
|
||||
}
|
||||
|
||||
type ApprovalTextAction = {
|
||||
kind?: "decision" | "command";
|
||||
decision?: ApprovalDecision;
|
||||
label: string;
|
||||
style?: string;
|
||||
command: string;
|
||||
};
|
||||
|
||||
// ============ Text Builders ============
|
||||
|
||||
export function buildExecApprovalText(request: ExecApprovalRequest): string {
|
||||
@@ -81,26 +73,7 @@ export function buildExecApprovalText(request: ExecApprovalRequest): string {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function appendCommandActionLines(lines: string[], actions: readonly ApprovalTextAction[]): void {
|
||||
const commandActions = actions.filter(
|
||||
(action) =>
|
||||
(action.kind === "command" || action.decision == null) &&
|
||||
typeof action.command === "string" &&
|
||||
action.command.trim().length > 0,
|
||||
);
|
||||
if (commandActions.length === 0) {
|
||||
return;
|
||||
}
|
||||
lines.push("", "\u{1f5e8}\ufe0f \u547d\u4ee4:");
|
||||
for (const action of commandActions) {
|
||||
lines.push(`- ${action.label}: ${action.command.trim()}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPluginApprovalText(
|
||||
request: PluginApprovalRequest,
|
||||
actions: readonly ApprovalTextAction[] = [],
|
||||
): string {
|
||||
export function buildPluginApprovalText(request: PluginApprovalRequest): string {
|
||||
const timeoutSec = Math.round((request.request.timeoutMs ?? 120_000) / 1000);
|
||||
const severityIcon =
|
||||
request.request.severity === "critical"
|
||||
@@ -123,7 +96,6 @@ export function buildPluginApprovalText(
|
||||
if (request.request.agentId) {
|
||||
lines.push(`\u{1f916} Agent: ${request.request.agentId}`);
|
||||
}
|
||||
appendCommandActionLines(lines, actions);
|
||||
lines.push("", `\u23f1\ufe0f \u8d85\u65f6: ${timeoutSec} \u79d2`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
@@ -17,23 +17,6 @@ function findSlackActionsBlock(blocks: Array<{ type?: string; elements?: unknown
|
||||
return blocks.find((block) => block.type === "actions");
|
||||
}
|
||||
|
||||
function collectSlackButtonValues(block?: { elements?: unknown[] }): string[] {
|
||||
return (block?.elements ?? []).flatMap((element) =>
|
||||
typeof element === "object" &&
|
||||
element &&
|
||||
typeof (element as { value?: unknown }).value === "string"
|
||||
? [(element as { value: string }).value]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
function collectSlackMrkdwnText(blocks: Array<{ text?: { text?: unknown } }>): string {
|
||||
return blocks
|
||||
.map((block) => (typeof block.text?.text === "string" ? block.text.text : ""))
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function readChatUpdatePayload(
|
||||
chatUpdate: { mock: { calls: unknown[][] } },
|
||||
index: number,
|
||||
@@ -144,21 +127,18 @@ describe("slackApprovalNativeRuntime", () => {
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "allow-once",
|
||||
label: "Allow Once",
|
||||
command: "/approve plugin:req-1 allow-once",
|
||||
style: "success",
|
||||
},
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "allow-always",
|
||||
label: "Allow Always",
|
||||
command: "/approve plugin:req-1 allow-always",
|
||||
style: "success",
|
||||
},
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "deny",
|
||||
label: "Deny",
|
||||
command: "/approve plugin:req-1 deny",
|
||||
@@ -188,124 +168,6 @@ describe("slackApprovalNativeRuntime", () => {
|
||||
expect(JSON.stringify(payload.blocks)).toContain("plugin:req-1");
|
||||
});
|
||||
|
||||
it("renders command-only plugin actions as visible command text", async () => {
|
||||
const payload = (await slackApprovalNativeRuntime.presentation.buildPendingPayload({
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
context: {
|
||||
app: {} as never,
|
||||
config: {} as never,
|
||||
},
|
||||
request: {
|
||||
id: "plugin:req-1",
|
||||
request: {
|
||||
title: "Verify with World",
|
||||
description: "World proof is required before the tool can continue.",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 60_000,
|
||||
},
|
||||
approvalKind: "plugin",
|
||||
nowMs: 0,
|
||||
view: {
|
||||
approvalKind: "plugin",
|
||||
phase: "pending",
|
||||
approvalId: "plugin:req-1",
|
||||
title: "Verify with World",
|
||||
description: "World proof is required before the tool can continue.",
|
||||
severity: "warning",
|
||||
pluginId: "agentkit",
|
||||
toolName: "exec",
|
||||
metadata: [{ label: "Plugin", value: "agentkit" }],
|
||||
actions: [
|
||||
{
|
||||
kind: "command",
|
||||
label: "Verify once",
|
||||
command: "/agentkit approve plugin:req-1 allow-once",
|
||||
style: "primary",
|
||||
},
|
||||
],
|
||||
expiresAtMs: 60_000,
|
||||
},
|
||||
})) as SlackPayload;
|
||||
|
||||
const blocks = payload.blocks as Array<{ type?: string; text?: { text?: unknown } }>;
|
||||
expect(payload.text).toContain("*Command actions*");
|
||||
expect(payload.text).toContain("/agentkit approve plugin:req-1 allow-once");
|
||||
expect(collectSlackMrkdwnText(blocks)).toContain("/agentkit approve plugin:req-1 allow-once");
|
||||
expect(blocks.some((block) => block.type === "actions")).toBe(false);
|
||||
});
|
||||
|
||||
it("renders mixed plugin actions with only decision actions as buttons", async () => {
|
||||
const payload = (await slackApprovalNativeRuntime.presentation.buildPendingPayload({
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
context: {
|
||||
app: {} as never,
|
||||
config: {} as never,
|
||||
},
|
||||
request: {
|
||||
id: "plugin:req-2",
|
||||
request: {
|
||||
title: "Verify with World",
|
||||
description: "World proof is required before the tool can continue.",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 60_000,
|
||||
},
|
||||
approvalKind: "plugin",
|
||||
nowMs: 0,
|
||||
view: {
|
||||
approvalKind: "plugin",
|
||||
phase: "pending",
|
||||
approvalId: "plugin:req-2",
|
||||
title: "Verify with World",
|
||||
description: "World proof is required before the tool can continue.",
|
||||
severity: "warning",
|
||||
pluginId: "agentkit",
|
||||
toolName: "exec",
|
||||
metadata: [{ label: "Plugin", value: "agentkit" }],
|
||||
actions: [
|
||||
{
|
||||
kind: "command",
|
||||
label: "Verify once",
|
||||
command: "/agentkit approve plugin:req-2 allow-once",
|
||||
style: "primary",
|
||||
},
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "deny",
|
||||
label: "Deny",
|
||||
command: "/agentkit deny plugin:req-2",
|
||||
style: "danger",
|
||||
},
|
||||
],
|
||||
expiresAtMs: 60_000,
|
||||
},
|
||||
})) as SlackPayload;
|
||||
|
||||
const blocks = payload.blocks as Array<{
|
||||
type?: string;
|
||||
elements?: unknown[];
|
||||
text?: { text?: unknown };
|
||||
}>;
|
||||
const actionsBlock = findSlackActionsBlock(blocks);
|
||||
const labels = (actionsBlock?.elements ?? []).map((element) =>
|
||||
typeof element === "object" &&
|
||||
element &&
|
||||
typeof (element as { text?: { text?: unknown } }).text?.text === "string"
|
||||
? (element as { text: { text: string } }).text.text
|
||||
: "",
|
||||
);
|
||||
|
||||
expect(collectSlackMrkdwnText(blocks)).toContain("/agentkit approve plugin:req-2 allow-once");
|
||||
expect(labels).toEqual(["Deny"]);
|
||||
expect(collectSlackButtonValues(actionsBlock)).toEqual(["/approve plugin:req-2 deny"]);
|
||||
expect(JSON.stringify(actionsBlock)).not.toContain("Verify once");
|
||||
expect(JSON.stringify(actionsBlock)).not.toContain("/agentkit approve plugin:req-2 allow-once");
|
||||
expect(JSON.stringify(actionsBlock)).not.toContain("/agentkit deny plugin:req-2");
|
||||
});
|
||||
|
||||
it("renders resolved updates without interactive blocks", async () => {
|
||||
const result = await slackApprovalNativeRuntime.presentation.buildResolvedResult({
|
||||
cfg: {} as never,
|
||||
|
||||
@@ -14,10 +14,7 @@ import type {
|
||||
} 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 {
|
||||
buildApprovalPresentationFromActionDescriptors,
|
||||
type ExecApprovalActionDescriptor,
|
||||
} from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
import { buildApprovalPresentationFromActionDescriptors } from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { logError } from "openclaw/plugin-sdk/logging-core";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
@@ -40,9 +37,6 @@ type SlackPendingDelivery = {
|
||||
text: string;
|
||||
blocks: SlackBlock[];
|
||||
};
|
||||
type SlackApprovalAction = PendingApprovalView["actions"][number];
|
||||
type SlackCommandApprovalAction = Extract<SlackApprovalAction, { kind: "command" }>;
|
||||
type SlackApprovalDecision = "allow-once" | "allow-always" | "deny";
|
||||
|
||||
const SLACK_CONTEXT_ELEMENTS_MAX = 10;
|
||||
const SLACK_CHAT_UPDATE_TEXT_LIMIT = 4000;
|
||||
@@ -126,106 +120,6 @@ function buildSlackMetadataContextElements(metadata: readonly { label: string; v
|
||||
return elements;
|
||||
}
|
||||
|
||||
function isSlackApprovalDecision(value: unknown): value is SlackApprovalDecision {
|
||||
return value === "allow-once" || value === "allow-always" || value === "deny";
|
||||
}
|
||||
|
||||
function isSlackCommandApprovalAction(
|
||||
action: SlackApprovalAction,
|
||||
): action is SlackCommandApprovalAction {
|
||||
return action.kind === "command" && action.command.trim().length > 0;
|
||||
}
|
||||
|
||||
function buildSlackApprovalCommandText(params: {
|
||||
approvalCommandId: string;
|
||||
decision: SlackApprovalDecision;
|
||||
}): string {
|
||||
return `/approve ${params.approvalCommandId} ${params.decision}`;
|
||||
}
|
||||
|
||||
function listSlackDecisionApprovalActions(
|
||||
approvalId: string,
|
||||
actions: readonly SlackApprovalAction[],
|
||||
): ExecApprovalActionDescriptor[] {
|
||||
const decisionActions: ExecApprovalActionDescriptor[] = [];
|
||||
for (const action of actions) {
|
||||
const raw = action as {
|
||||
command?: unknown;
|
||||
decision?: unknown;
|
||||
kind?: unknown;
|
||||
label?: unknown;
|
||||
style?: unknown;
|
||||
};
|
||||
const kind = raw.kind === "command" ? "command" : "decision";
|
||||
if (kind !== "decision" || !isSlackApprovalDecision(raw.decision)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
typeof raw.label !== "string" ||
|
||||
typeof raw.command !== "string" ||
|
||||
(raw.style !== "primary" &&
|
||||
raw.style !== "secondary" &&
|
||||
raw.style !== "success" &&
|
||||
raw.style !== "danger")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
decisionActions.push({
|
||||
kind: "decision",
|
||||
decision: raw.decision,
|
||||
label: raw.label,
|
||||
command: buildSlackApprovalCommandText({
|
||||
approvalCommandId: approvalId,
|
||||
decision: raw.decision,
|
||||
}),
|
||||
style: raw.style,
|
||||
});
|
||||
}
|
||||
return decisionActions;
|
||||
}
|
||||
|
||||
function buildSlackDecisionActionBlocks(view: PendingApprovalView): SlackBlock[] {
|
||||
const decisionActions = listSlackDecisionApprovalActions(view.approvalId, view.actions);
|
||||
return (
|
||||
resolveSlackReplyBlocks({
|
||||
text: "",
|
||||
presentation: buildApprovalPresentationFromActionDescriptors(decisionActions),
|
||||
}) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
function buildSlackCommandActionTextLines(actions: readonly SlackApprovalAction[]): string[] {
|
||||
const commandActions = actions.filter(isSlackCommandApprovalAction);
|
||||
if (commandActions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
"",
|
||||
"*Command actions*",
|
||||
...commandActions.flatMap((action) => [
|
||||
truncateSlackMrkdwn(action.label, 120),
|
||||
buildSlackCodeBlock(action.command.trim()),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
function buildSlackCommandActionBlocks(actions: readonly SlackApprovalAction[]): SlackBlock[] {
|
||||
const commandActions = actions.filter(isSlackCommandApprovalAction);
|
||||
return commandActions.map((action, index) => {
|
||||
const label = truncateSlackMrkdwn(action.label, 120);
|
||||
const command = truncateSlackMrkdwn(action.command.trim(), 2600);
|
||||
return {
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: `${index === 0 ? "*Command actions*\n" : ""}${label}\n${buildSlackCodeBlock(
|
||||
command,
|
||||
)}`,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resolveSlackApprovalDecisionLabel(
|
||||
decision: "allow-once" | "allow-always" | "deny",
|
||||
): string {
|
||||
@@ -257,7 +151,6 @@ function buildSlackExecPendingApprovalText(view: ExecApprovalPendingView): strin
|
||||
"*Command*",
|
||||
buildSlackCodeBlock(view.commandText),
|
||||
...metadataLines,
|
||||
...buildSlackCommandActionTextLines(view.actions),
|
||||
];
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -271,7 +164,6 @@ function buildSlackPluginPendingApprovalText(view: PluginApprovalPendingView): s
|
||||
"*Request*",
|
||||
view.title,
|
||||
...metadataLines,
|
||||
...buildSlackCommandActionTextLines(view.actions),
|
||||
];
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -284,8 +176,11 @@ function buildSlackPendingApprovalText(view: PendingApprovalView): string {
|
||||
|
||||
function buildSlackExecPendingApprovalBlocks(view: ExecApprovalPendingView): SlackBlock[] {
|
||||
const metadataElements = buildSlackMetadataContextElements(view.metadata);
|
||||
const commandActionBlocks = buildSlackCommandActionBlocks(view.actions);
|
||||
const interactiveBlocks = buildSlackDecisionActionBlocks(view);
|
||||
const interactiveBlocks =
|
||||
resolveSlackReplyBlocks({
|
||||
text: "",
|
||||
presentation: buildApprovalPresentationFromActionDescriptors(view.actions),
|
||||
}) ?? [];
|
||||
return [
|
||||
{
|
||||
type: "section",
|
||||
@@ -309,15 +204,17 @@ function buildSlackExecPendingApprovalBlocks(view: ExecApprovalPendingView): Sla
|
||||
} satisfies SlackBlock,
|
||||
]
|
||||
: []),
|
||||
...commandActionBlocks,
|
||||
...interactiveBlocks,
|
||||
];
|
||||
}
|
||||
|
||||
function buildSlackPluginPendingApprovalBlocks(view: PluginApprovalPendingView): SlackBlock[] {
|
||||
const metadataElements = buildSlackMetadataContextElements(buildSlackPluginMetadata(view));
|
||||
const commandActionBlocks = buildSlackCommandActionBlocks(view.actions);
|
||||
const interactiveBlocks = buildSlackDecisionActionBlocks(view);
|
||||
const interactiveBlocks =
|
||||
resolveSlackReplyBlocks({
|
||||
text: "",
|
||||
presentation: buildApprovalPresentationFromActionDescriptors(view.actions),
|
||||
}) ?? [];
|
||||
return [
|
||||
{
|
||||
type: "section",
|
||||
@@ -344,7 +241,6 @@ function buildSlackPluginPendingApprovalBlocks(view: PluginApprovalPendingView):
|
||||
} satisfies SlackBlock,
|
||||
]
|
||||
: []),
|
||||
...commandActionBlocks,
|
||||
...interactiveBlocks,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { telegramApprovalNativeRuntime } from "./approval-handler.runtime.js";
|
||||
|
||||
type TelegramPayload = {
|
||||
text: string;
|
||||
buttons?: Array<Array<{ text: string; callback_data?: string }>>;
|
||||
buttons?: Array<Array<{ text: string }>>;
|
||||
};
|
||||
|
||||
describe("telegramApprovalNativeRuntime", () => {
|
||||
@@ -30,14 +30,12 @@ describe("telegramApprovalNativeRuntime", () => {
|
||||
commandText: "echo hi",
|
||||
actions: [
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "allow-once",
|
||||
label: "Allow Once",
|
||||
command: "/approve req-1 allow-once",
|
||||
style: "success",
|
||||
},
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "deny",
|
||||
label: "Deny",
|
||||
command: "/approve req-1 deny",
|
||||
@@ -47,74 +45,11 @@ describe("telegramApprovalNativeRuntime", () => {
|
||||
} as never,
|
||||
})) as TelegramPayload;
|
||||
|
||||
expect(payload.text).toContain("echo hi");
|
||||
expect(payload.text).toContain("/approve req-1 allow-once");
|
||||
expect(payload.text).not.toContain("allow-always");
|
||||
expect(payload.buttons?.[0]?.map((button) => button.text)).toEqual(["Allow Once", "Deny"]);
|
||||
});
|
||||
|
||||
it("keeps plugin command actions as text instead of Telegram callbacks", async () => {
|
||||
const payload = (await telegramApprovalNativeRuntime.presentation.buildPendingPayload({
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
context: {
|
||||
token: "tg-token",
|
||||
},
|
||||
request: {
|
||||
id: "plugin-req-1",
|
||||
request: {
|
||||
title: "World ID proof",
|
||||
description: "Approve the verified proof.",
|
||||
actions: [
|
||||
{
|
||||
kind: "command",
|
||||
label: "Open AgentKit",
|
||||
style: "primary",
|
||||
command: "/agentkit approve plugin-req-1",
|
||||
},
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "deny",
|
||||
label: "Deny",
|
||||
style: "danger",
|
||||
command: "/agentkit deny plugin-req-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 60_000,
|
||||
},
|
||||
approvalKind: "plugin",
|
||||
nowMs: 0,
|
||||
view: {
|
||||
approvalKind: "plugin",
|
||||
approvalId: "plugin-req-1",
|
||||
title: "World ID proof",
|
||||
severity: "warning",
|
||||
actions: [
|
||||
{
|
||||
kind: "command",
|
||||
label: "Open AgentKit",
|
||||
command: "/agentkit approve plugin-req-1",
|
||||
style: "primary",
|
||||
},
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "deny",
|
||||
label: "Deny",
|
||||
command: "/agentkit deny plugin-req-1",
|
||||
style: "danger",
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
})) as TelegramPayload;
|
||||
|
||||
expect(payload.text).toContain("/agentkit approve plugin-req-1");
|
||||
expect(payload.text).toContain("/agentkit deny plugin-req-1");
|
||||
expect(payload.buttons).toEqual([
|
||||
[{ text: "Deny", callback_data: "/approve plugin-req-1 deny", style: "danger" }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes topic thread ids to typing and message delivery", async () => {
|
||||
const sendTyping = vi.fn().mockResolvedValue({ ok: true });
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -9,11 +9,7 @@ import {
|
||||
buildApprovalPresentationFromActionDescriptors,
|
||||
buildExecApprovalPendingReplyPayload,
|
||||
} from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
import type {
|
||||
ExecApprovalActionDescriptor,
|
||||
ExecApprovalPendingReplyParams,
|
||||
ExecApprovalReplyDecision,
|
||||
} from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
import type { ExecApprovalPendingReplyParams } from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
import type {
|
||||
ExecApprovalRequest,
|
||||
PluginApprovalRequest,
|
||||
@@ -63,36 +59,6 @@ function resolveHandlerContext(params: ChannelApprovalCapabilityHandlerContext):
|
||||
return { accountId, context };
|
||||
}
|
||||
|
||||
function listDecisionActions(view: PendingApprovalView): ExecApprovalReplyDecision[] {
|
||||
return view.actions.flatMap((action) => (action.kind === "decision" ? [action.decision] : []));
|
||||
}
|
||||
|
||||
function buildTelegramApprovalCommandText(params: {
|
||||
approvalCommandId: string;
|
||||
decision: ExecApprovalReplyDecision;
|
||||
}): string {
|
||||
return `/approve ${params.approvalCommandId} ${params.decision}`;
|
||||
}
|
||||
|
||||
function listNativeButtonActions(view: PendingApprovalView): ExecApprovalActionDescriptor[] {
|
||||
return view.actions.flatMap((action) =>
|
||||
action.kind === "decision"
|
||||
? [
|
||||
{
|
||||
kind: "decision",
|
||||
decision: action.decision,
|
||||
label: action.label,
|
||||
style: action.style,
|
||||
command: buildTelegramApprovalCommandText({
|
||||
approvalCommandId: view.approvalId,
|
||||
decision: action.decision,
|
||||
}),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
function buildPendingPayload(params: {
|
||||
request: ApprovalRequest;
|
||||
approvalKind: "exec" | "plugin";
|
||||
@@ -119,16 +85,14 @@ function buildPendingPayload(params: {
|
||||
params.view.approvalKind === "exec" && params.view.host === "node" ? "node" : "gateway",
|
||||
nodeId:
|
||||
params.view.approvalKind === "exec" ? (params.view.nodeId ?? undefined) : undefined,
|
||||
allowedDecisions: listDecisionActions(params.view),
|
||||
allowedDecisions: params.view.actions.map((action) => action.decision),
|
||||
expiresAtMs: params.request.expiresAtMs,
|
||||
nowMs: params.nowMs,
|
||||
} satisfies ExecApprovalPendingReplyParams);
|
||||
return {
|
||||
text: payload.text ?? "",
|
||||
buttons: resolveTelegramInlineButtons({
|
||||
presentation: buildApprovalPresentationFromActionDescriptors(
|
||||
listNativeButtonActions(params.view),
|
||||
),
|
||||
presentation: buildApprovalPresentationFromActionDescriptors(params.view.actions),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,14 +23,12 @@ describe("whatsappApprovalNativeRuntime", () => {
|
||||
commandText: "echo hi",
|
||||
actions: [
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "allow-once",
|
||||
label: "Allow Once",
|
||||
command: "/approve exec-1 allow-once",
|
||||
style: "success",
|
||||
},
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "deny",
|
||||
label: "Deny",
|
||||
command: "/approve exec-1 deny",
|
||||
@@ -75,21 +73,18 @@ describe("whatsappApprovalNativeRuntime", () => {
|
||||
severity: "warning",
|
||||
actions: [
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "allow-once",
|
||||
label: "Allow Once",
|
||||
command: "/approve plugin:abc allow-once",
|
||||
style: "success",
|
||||
},
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "allow-always",
|
||||
label: "Allow Always",
|
||||
command: "/approve plugin:abc allow-always",
|
||||
style: "primary",
|
||||
},
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "deny",
|
||||
label: "Deny",
|
||||
command: "/approve plugin:abc deny",
|
||||
|
||||
@@ -1260,51 +1260,6 @@ describe("before_tool_call requireApproval handling", () => {
|
||||
expect(waitCall[2]).toEqual({ id: "server-id-1" });
|
||||
});
|
||||
|
||||
it("passes plugin approval actions and no-route pending preference to the gateway", async () => {
|
||||
hookRunner.runBeforeToolCall.mockResolvedValue({
|
||||
requireApproval: {
|
||||
title: "Sensitive",
|
||||
description: "Sensitive op",
|
||||
pluginId: "agentkit",
|
||||
allowedDecisions: ["deny"],
|
||||
keepPendingWithoutRoute: true,
|
||||
actions: [
|
||||
{
|
||||
kind: "command",
|
||||
label: "Open AgentKit",
|
||||
style: "primary",
|
||||
commandTemplate: "/agentkit approve {id}",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
mockCallGateway.mockResolvedValueOnce({ id: "server-id-actions", status: "accepted" });
|
||||
mockCallGateway.mockResolvedValueOnce({ id: "server-id-actions", decision: "deny" });
|
||||
|
||||
const result = await runBeforeToolCallHook({
|
||||
toolName: "bash",
|
||||
params: { command: "rm -rf" },
|
||||
ctx: { agentId: "main", sessionKey: "main" },
|
||||
});
|
||||
|
||||
expect(result.blocked).toBe(true);
|
||||
const requestParams = requireRecord(requireGatewayCall(0)[2], "approval request params");
|
||||
expect(requestParams).toMatchObject({
|
||||
pluginId: "agentkit",
|
||||
allowedDecisions: ["deny"],
|
||||
keepPendingWithoutRoute: true,
|
||||
actions: [
|
||||
{
|
||||
kind: "command",
|
||||
label: "Open AgentKit",
|
||||
style: "primary",
|
||||
commandTemplate: "/agentkit approve {id}",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks on deny decision", async () => {
|
||||
hookRunner.runBeforeToolCall.mockResolvedValue({
|
||||
requireApproval: {
|
||||
|
||||
@@ -404,8 +404,6 @@ async function requestPluginToolApproval(params: {
|
||||
description: approval.description,
|
||||
severity: approval.severity,
|
||||
allowedDecisions: approval.allowedDecisions,
|
||||
actions: approval.actions,
|
||||
keepPendingWithoutRoute: approval.keepPendingWithoutRoute,
|
||||
toolName: params.toolName,
|
||||
toolCallId: params.toolCallId,
|
||||
agentId: params.ctx?.agentId,
|
||||
|
||||
@@ -1,28 +1,11 @@
|
||||
import { Type } from "typebox";
|
||||
import {
|
||||
MAX_PLUGIN_APPROVAL_ACTIONS,
|
||||
MAX_PLUGIN_APPROVAL_TIMEOUT_MS,
|
||||
PLUGIN_APPROVAL_ACTION_COMMAND_TEMPLATE_MAX_LENGTH,
|
||||
PLUGIN_APPROVAL_ACTION_LABEL_MAX_LENGTH,
|
||||
PLUGIN_APPROVAL_DESCRIPTION_MAX_LENGTH,
|
||||
PLUGIN_APPROVAL_TITLE_MAX_LENGTH,
|
||||
} from "../../../infra/plugin-approvals.js";
|
||||
import { NonEmptyString } from "./primitives.js";
|
||||
|
||||
const PluginApprovalActionTemplateSchema = Type.Object(
|
||||
{
|
||||
kind: Type.String({ enum: ["decision", "command"] }),
|
||||
label: Type.String({ minLength: 1, maxLength: PLUGIN_APPROVAL_ACTION_LABEL_MAX_LENGTH }),
|
||||
style: Type.String({ enum: ["primary", "secondary", "success", "danger"] }),
|
||||
decision: Type.Optional(Type.String({ enum: ["allow-once", "allow-always", "deny"] })),
|
||||
commandTemplate: Type.String({
|
||||
minLength: 1,
|
||||
maxLength: PLUGIN_APPROVAL_ACTION_COMMAND_TEMPLATE_MAX_LENGTH,
|
||||
}),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const PluginApprovalRequestParamsSchema = Type.Object(
|
||||
{
|
||||
pluginId: Type.Optional(NonEmptyString),
|
||||
@@ -37,12 +20,6 @@ export const PluginApprovalRequestParamsSchema = Type.Object(
|
||||
maxItems: 3,
|
||||
}),
|
||||
),
|
||||
actions: Type.Optional(
|
||||
Type.Array(PluginApprovalActionTemplateSchema, {
|
||||
minItems: 1,
|
||||
maxItems: MAX_PLUGIN_APPROVAL_ACTIONS,
|
||||
}),
|
||||
),
|
||||
agentId: Type.Optional(Type.String()),
|
||||
sessionKey: Type.Optional(Type.String()),
|
||||
turnSourceChannel: Type.Optional(Type.String()),
|
||||
@@ -51,7 +28,6 @@ export const PluginApprovalRequestParamsSchema = Type.Object(
|
||||
turnSourceThreadId: Type.Optional(Type.Union([Type.String(), Type.Number()])),
|
||||
timeoutMs: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_PLUGIN_APPROVAL_TIMEOUT_MS })),
|
||||
twoPhase: Type.Optional(Type.Boolean()),
|
||||
keepPendingWithoutRoute: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
@@ -281,7 +281,6 @@ export async function handlePendingApprovalRequest<
|
||||
requestEvent: RequestedApprovalEvent<TPayload>;
|
||||
twoPhase: boolean;
|
||||
approvalKind?: "exec" | "plugin";
|
||||
keepPendingWithoutRoute?: boolean;
|
||||
deliverRequest: () => boolean | Promise<boolean>;
|
||||
afterDecision?: (
|
||||
decision: ExecApprovalDecision | null,
|
||||
@@ -322,7 +321,7 @@ export async function handlePendingApprovalRequest<
|
||||
approvalKind: params.approvalKind ?? "exec",
|
||||
});
|
||||
|
||||
if (!hasApprovalClients && !hasTurnSourceRoute && !delivered && !params.keepPendingWithoutRoute) {
|
||||
if (!hasApprovalClients && !hasTurnSourceRoute && !delivered) {
|
||||
params.manager.expire(params.record.id, "no-approval-route");
|
||||
params.respond(
|
||||
true,
|
||||
|
||||
@@ -233,39 +233,6 @@ describe("createPluginApprovalHandlers", () => {
|
||||
expect(responseCall(opts.respond as unknown as MockCallSource).error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps plugin approvals pending without an approval route when requested", async () => {
|
||||
const handlers = createPluginApprovalHandlers(manager);
|
||||
const respond = vi.fn();
|
||||
const opts = createMockOptions(
|
||||
"plugin.approval.request",
|
||||
{
|
||||
title: "Sensitive action",
|
||||
description: "Desc",
|
||||
twoPhase: true,
|
||||
keepPendingWithoutRoute: true,
|
||||
},
|
||||
{
|
||||
respond,
|
||||
context: createNoExecApprovalContext(),
|
||||
},
|
||||
);
|
||||
|
||||
const handlerPromise = handlers["plugin.approval.request"](opts);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const accepted = acceptedResult(respond as unknown as MockCallSource);
|
||||
expect(accepted.status).toBe("accepted");
|
||||
expect(accepted.id).toBeTypeOf("string");
|
||||
});
|
||||
|
||||
const approvalId = acceptedApprovalId(respond as unknown as MockCallSource);
|
||||
expect(manager.getSnapshot(approvalId)).not.toBeNull();
|
||||
manager.resolve(approvalId, "allow-once");
|
||||
await handlerPromise;
|
||||
|
||||
expect(responseResult(respond as unknown as MockCallSource, 1).decision).toBe("allow-once");
|
||||
});
|
||||
|
||||
it("passes caller connId to hasExecApprovalClients to exclude self", async () => {
|
||||
const handlers = createPluginApprovalHandlers(manager);
|
||||
const hasExecApprovalClients = vi.fn().mockReturnValue(false);
|
||||
@@ -375,55 +342,6 @@ describe("createPluginApprovalHandlers", () => {
|
||||
expect(responseError(opts.respond as unknown as MockCallSource).code).toBeTypeOf("string");
|
||||
});
|
||||
|
||||
it("rejects command approval actions that carry approval decisions", async () => {
|
||||
const handlers = createPluginApprovalHandlers(manager);
|
||||
const opts = createMockOptions("plugin.approval.request", {
|
||||
title: "T",
|
||||
description: "D",
|
||||
actions: [
|
||||
{
|
||||
kind: "command",
|
||||
label: "Verify externally",
|
||||
style: "primary",
|
||||
decision: "allow-once",
|
||||
commandTemplate: "/agentkit approve {id}",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await handlers["plugin.approval.request"](opts);
|
||||
|
||||
expect(responseCall(opts.respond as unknown as MockCallSource).ok).toBe(false);
|
||||
expect(responseError(opts.respond as unknown as MockCallSource).message).toContain(
|
||||
"command actions must not include decision",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects decision approval actions outside allowed decisions", async () => {
|
||||
const handlers = createPluginApprovalHandlers(manager);
|
||||
const opts = createMockOptions("plugin.approval.request", {
|
||||
title: "T",
|
||||
description: "D",
|
||||
allowedDecisions: ["deny"],
|
||||
actions: [
|
||||
{
|
||||
kind: "decision",
|
||||
label: "Allow once",
|
||||
style: "primary",
|
||||
decision: "allow-once",
|
||||
commandTemplate: "/approve {id} allow-once",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await handlers["plugin.approval.request"](opts);
|
||||
|
||||
expect(responseCall(opts.respond as unknown as MockCallSource).ok).toBe(false);
|
||||
expect(responseError(opts.respond as unknown as MockCallSource).message).toContain(
|
||||
"decision allow-once is not in allowedDecisions",
|
||||
);
|
||||
});
|
||||
|
||||
it("generates plugin-prefixed IDs", async () => {
|
||||
const handlers = createPluginApprovalHandlers(manager);
|
||||
const respond = vi.fn();
|
||||
@@ -507,64 +425,6 @@ describe("createPluginApprovalHandlers", () => {
|
||||
manager.resolve(approvalId, "deny");
|
||||
await handlerPromise;
|
||||
});
|
||||
|
||||
it("stores plugin approval action templates as expanded commands", async () => {
|
||||
const handlers = createPluginApprovalHandlers(manager);
|
||||
const respond = vi.fn();
|
||||
const opts = createMockOptions(
|
||||
"plugin.approval.request",
|
||||
{
|
||||
title: "T",
|
||||
description: "D",
|
||||
actions: [
|
||||
{
|
||||
kind: "command",
|
||||
label: "Open AgentKit",
|
||||
style: "primary",
|
||||
commandTemplate: "/agentkit approve {id}",
|
||||
},
|
||||
{
|
||||
kind: "decision",
|
||||
label: "Deny",
|
||||
style: "danger",
|
||||
decision: "deny",
|
||||
commandTemplate: "/agentkit deny {id}",
|
||||
},
|
||||
],
|
||||
twoPhase: true,
|
||||
},
|
||||
{ respond },
|
||||
);
|
||||
|
||||
const handlerPromise = handlers["plugin.approval.request"](opts);
|
||||
await vi.waitFor(() => {
|
||||
const accepted = acceptedResult(respond as unknown as MockCallSource);
|
||||
expect(accepted.status).toBe("accepted");
|
||||
expect(accepted.id).toBeTypeOf("string");
|
||||
});
|
||||
|
||||
const approvalId = acceptedApprovalId(respond as unknown as MockCallSource);
|
||||
const expectedActions = [
|
||||
{
|
||||
kind: "command",
|
||||
label: "Open AgentKit",
|
||||
style: "primary",
|
||||
command: `/agentkit approve ${approvalId}`,
|
||||
},
|
||||
{
|
||||
kind: "decision",
|
||||
label: "Deny",
|
||||
style: "danger",
|
||||
decision: "deny",
|
||||
command: `/agentkit deny ${approvalId}`,
|
||||
},
|
||||
];
|
||||
expect(manager.getSnapshot(approvalId)?.request.actions).toEqual(expectedActions);
|
||||
expect(broadcastCall(opts).payload.request).toMatchObject({ actions: expectedActions });
|
||||
|
||||
manager.resolve(approvalId, "deny");
|
||||
await handlerPromise;
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin.approval.list", () => {
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { ExecApprovalForwarder } from "../../infra/exec-approval-forwarder.js";
|
||||
import type { ExecApprovalDecision } from "../../infra/exec-approvals.js";
|
||||
import type {
|
||||
PluginApprovalActionTemplate,
|
||||
PluginApprovalRequestPayload,
|
||||
} from "../../infra/plugin-approvals.js";
|
||||
import type { PluginApprovalRequestPayload } from "../../infra/plugin-approvals.js";
|
||||
import {
|
||||
DEFAULT_PLUGIN_APPROVAL_TIMEOUT_MS,
|
||||
MAX_PLUGIN_APPROVAL_TIMEOUT_MS,
|
||||
expandPluginApprovalActionTemplates,
|
||||
resolvePluginApprovalRequestAllowedDecisions,
|
||||
validatePluginApprovalActionTemplates,
|
||||
} from "../../infra/plugin-approvals.js";
|
||||
import { normalizeOptionalString } from "../../shared/string-coerce.js";
|
||||
import type { ExecApprovalManager } from "../exec-approval-manager.js";
|
||||
@@ -30,21 +25,6 @@ import {
|
||||
} from "./approval-shared.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
function validateDecisionActionAvailability(params: {
|
||||
actions?: readonly PluginApprovalActionTemplate[] | null;
|
||||
allowedDecisions: readonly ExecApprovalDecision[];
|
||||
}): string | null {
|
||||
if (!Array.isArray(params.actions)) {
|
||||
return null;
|
||||
}
|
||||
for (const [index, action] of params.actions.entries()) {
|
||||
if (action.kind === "decision" && !params.allowedDecisions.includes(action.decision)) {
|
||||
return `actions[${index}] decision ${action.decision} is not in allowedDecisions`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createPluginApprovalHandlers(
|
||||
manager: ExecApprovalManager<PluginApprovalRequestPayload>,
|
||||
opts?: { forwarder?: ExecApprovalForwarder },
|
||||
@@ -87,7 +67,6 @@ export function createPluginApprovalHandlers(
|
||||
toolName?: string | null;
|
||||
toolCallId?: string | null;
|
||||
allowedDecisions?: string[] | null;
|
||||
actions?: PluginApprovalActionTemplate[] | null;
|
||||
agentId?: string | null;
|
||||
sessionKey?: string | null;
|
||||
turnSourceChannel?: string | null;
|
||||
@@ -96,26 +75,7 @@ export function createPluginApprovalHandlers(
|
||||
turnSourceThreadId?: string | number | null;
|
||||
timeoutMs?: number;
|
||||
twoPhase?: boolean;
|
||||
keepPendingWithoutRoute?: boolean;
|
||||
};
|
||||
const actionTemplateError = Array.isArray(p.actions)
|
||||
? validatePluginApprovalActionTemplates(p.actions)
|
||||
: null;
|
||||
if (actionTemplateError) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, actionTemplateError));
|
||||
return;
|
||||
}
|
||||
const allowedDecisions = resolvePluginApprovalRequestAllowedDecisions({
|
||||
allowedDecisions: Array.isArray(p.allowedDecisions) ? p.allowedDecisions : null,
|
||||
});
|
||||
const decisionActionError = validateDecisionActionAvailability({
|
||||
actions: p.actions,
|
||||
allowedDecisions,
|
||||
});
|
||||
if (decisionActionError) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, decisionActionError));
|
||||
return;
|
||||
}
|
||||
const twoPhase = p.twoPhase === true;
|
||||
const timeoutMs = Math.min(
|
||||
typeof p.timeoutMs === "number" ? p.timeoutMs : DEFAULT_PLUGIN_APPROVAL_TIMEOUT_MS,
|
||||
@@ -125,9 +85,6 @@ export function createPluginApprovalHandlers(
|
||||
const normalizeTrimmedString = (value?: string | null): string | null =>
|
||||
normalizeOptionalString(value) || null;
|
||||
|
||||
// Always server-generate the ID — never accept plugin-provided IDs.
|
||||
// Kind-prefix so /approve routing can distinguish plugin vs exec IDs deterministically.
|
||||
const approvalId = `plugin:${randomUUID()}`;
|
||||
const request: PluginApprovalRequestPayload = {
|
||||
pluginId: p.pluginId ?? null,
|
||||
title: p.title,
|
||||
@@ -135,9 +92,12 @@ export function createPluginApprovalHandlers(
|
||||
severity: (p.severity as PluginApprovalRequestPayload["severity"]) ?? null,
|
||||
toolName: p.toolName ?? null,
|
||||
toolCallId: p.toolCallId ?? null,
|
||||
...(Array.isArray(p.allowedDecisions) ? { allowedDecisions } : {}),
|
||||
...(Array.isArray(p.actions)
|
||||
? { actions: expandPluginApprovalActionTemplates({ approvalId, actions: p.actions }) }
|
||||
...(Array.isArray(p.allowedDecisions)
|
||||
? {
|
||||
allowedDecisions: resolvePluginApprovalRequestAllowedDecisions({
|
||||
allowedDecisions: p.allowedDecisions,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
agentId: p.agentId ?? null,
|
||||
sessionKey: p.sessionKey ?? null,
|
||||
@@ -147,7 +107,9 @@ export function createPluginApprovalHandlers(
|
||||
turnSourceThreadId: p.turnSourceThreadId ?? null,
|
||||
};
|
||||
|
||||
const record = manager.create(request, timeoutMs, approvalId);
|
||||
// Always server-generate the ID — never accept plugin-provided IDs.
|
||||
// Kind-prefix so /approve routing can distinguish plugin vs exec IDs deterministically.
|
||||
const record = manager.create(request, timeoutMs, `plugin:${randomUUID()}`);
|
||||
record.requestedByConnId = client?.connId ?? null;
|
||||
record.requestedByDeviceId = client?.connect?.device?.id ?? null;
|
||||
record.requestedByClientId = client?.connect?.client?.id ?? null;
|
||||
@@ -183,7 +145,6 @@ export function createPluginApprovalHandlers(
|
||||
requestEvent,
|
||||
twoPhase,
|
||||
approvalKind: "plugin",
|
||||
keepPendingWithoutRoute: p.keepPendingWithoutRoute === true,
|
||||
deliverRequest: () => {
|
||||
if (!opts?.forwarder?.handlePluginApprovalRequested) {
|
||||
return false;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildPendingApprovalView } from "./approval-view-model.js";
|
||||
import type { ExecApprovalRequest } from "./exec-approvals.js";
|
||||
import type { PluginApprovalRequest } from "./plugin-approvals.js";
|
||||
|
||||
describe("buildPendingApprovalView", () => {
|
||||
it("passes command analysis through exec approval views", () => {
|
||||
@@ -30,36 +29,4 @@ describe("buildPendingApprovalView", () => {
|
||||
}
|
||||
expect(view.commandAnalysis?.warningLines).toEqual(["Contains inline-eval: python -c"]);
|
||||
});
|
||||
|
||||
it("uses custom plugin approval actions when provided", () => {
|
||||
const request: PluginApprovalRequest = {
|
||||
id: "plugin:approval-1",
|
||||
createdAtMs: 1,
|
||||
expiresAtMs: 2,
|
||||
request: {
|
||||
title: "World ID approval",
|
||||
description: "Approve in World app",
|
||||
actions: [
|
||||
{
|
||||
kind: "command",
|
||||
label: "Open AgentKit",
|
||||
style: "primary",
|
||||
command: "/agentkit approve plugin:approval-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const view = buildPendingApprovalView(request);
|
||||
|
||||
expect(view.approvalKind).toBe("plugin");
|
||||
expect(view.actions).toEqual([
|
||||
{
|
||||
kind: "command",
|
||||
label: "Open AgentKit",
|
||||
style: "primary",
|
||||
command: "/agentkit approve plugin:approval-1",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type {
|
||||
ApprovalMetadataView,
|
||||
ApprovalActionView,
|
||||
ApprovalRequest,
|
||||
ApprovalResolved,
|
||||
ExecApprovalViewBase,
|
||||
@@ -10,10 +9,7 @@ import type {
|
||||
ResolvedApprovalView,
|
||||
} from "./approval-view-model.types.js";
|
||||
import { resolveExecApprovalCommandDisplay } from "./exec-approval-command-display.js";
|
||||
import {
|
||||
buildExecApprovalActionDescriptors,
|
||||
type ExecApprovalActionDescriptor,
|
||||
} from "./exec-approval-reply.js";
|
||||
import { buildExecApprovalActionDescriptors } from "./exec-approval-reply.js";
|
||||
import {
|
||||
resolveExecApprovalRequestAllowedDecisions,
|
||||
type ExecApprovalRequest,
|
||||
@@ -25,35 +21,6 @@ import {
|
||||
|
||||
type ApprovalPhase = "pending" | "resolved" | "expired";
|
||||
|
||||
function buildApprovalActionViewsFromDescriptors(
|
||||
actions: readonly ExecApprovalActionDescriptor[],
|
||||
): ApprovalActionView[] {
|
||||
const views: ApprovalActionView[] = [];
|
||||
for (const action of actions) {
|
||||
const kind = action.kind ?? "decision";
|
||||
if (kind === "command") {
|
||||
views.push({
|
||||
kind: "command",
|
||||
label: action.label,
|
||||
style: action.style,
|
||||
command: action.command,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!action.decision) {
|
||||
continue;
|
||||
}
|
||||
views.push({
|
||||
kind: "decision",
|
||||
decision: action.decision,
|
||||
label: action.label,
|
||||
style: action.style,
|
||||
command: action.command,
|
||||
});
|
||||
}
|
||||
return views;
|
||||
}
|
||||
|
||||
function buildExecMetadata(request: ExecApprovalRequest): ApprovalMetadataView[] {
|
||||
const metadata: ApprovalMetadataView[] = [];
|
||||
if (request.request.agentId) {
|
||||
@@ -134,53 +101,26 @@ function buildPluginViewBase<TPhase extends ApprovalPhase>(
|
||||
};
|
||||
}
|
||||
|
||||
function buildPluginApprovalActions(request: PluginApprovalRequest): ApprovalActionView[] {
|
||||
if (Array.isArray(request.request.actions) && request.request.actions.length > 0) {
|
||||
return request.request.actions.map((action) => {
|
||||
if (action.kind === "decision") {
|
||||
return {
|
||||
kind: "decision",
|
||||
decision: action.decision,
|
||||
label: action.label,
|
||||
style: action.style,
|
||||
command: action.command,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "command",
|
||||
label: action.label,
|
||||
style: action.style,
|
||||
command: action.command,
|
||||
};
|
||||
});
|
||||
}
|
||||
return buildApprovalActionViewsFromDescriptors(
|
||||
buildExecApprovalActionDescriptors({
|
||||
approvalCommandId: request.id,
|
||||
allowedDecisions: resolvePluginApprovalRequestAllowedDecisions(request.request),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildPendingApprovalView(request: ApprovalRequest): PendingApprovalView {
|
||||
if (request.id.startsWith("plugin:")) {
|
||||
const pluginRequest = request as PluginApprovalRequest;
|
||||
return {
|
||||
...buildPluginViewBase(pluginRequest, "pending"),
|
||||
actions: buildPluginApprovalActions(pluginRequest),
|
||||
actions: buildExecApprovalActionDescriptors({
|
||||
approvalCommandId: pluginRequest.id,
|
||||
allowedDecisions: resolvePluginApprovalRequestAllowedDecisions(pluginRequest.request),
|
||||
}),
|
||||
expiresAtMs: pluginRequest.expiresAtMs,
|
||||
};
|
||||
}
|
||||
const execRequest = request as ExecApprovalRequest;
|
||||
return {
|
||||
...buildExecViewBase(execRequest, "pending"),
|
||||
actions: buildApprovalActionViewsFromDescriptors(
|
||||
buildExecApprovalActionDescriptors({
|
||||
approvalCommandId: execRequest.id,
|
||||
ask: execRequest.request.ask,
|
||||
allowedDecisions: resolveExecApprovalRequestAllowedDecisions(execRequest.request),
|
||||
}),
|
||||
),
|
||||
actions: buildExecApprovalActionDescriptors({
|
||||
approvalCommandId: execRequest.id,
|
||||
ask: execRequest.request.ask,
|
||||
allowedDecisions: resolveExecApprovalRequestAllowedDecisions(execRequest.request),
|
||||
}),
|
||||
expiresAtMs: execRequest.expiresAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,23 +10,13 @@ import type { PluginApprovalRequest, PluginApprovalResolved } from "./plugin-app
|
||||
|
||||
type ApprovalPhase = "pending" | "resolved" | "expired";
|
||||
|
||||
export type ApprovalDecisionActionView = {
|
||||
kind: "decision";
|
||||
export type ApprovalActionView = {
|
||||
decision: ExecApprovalDecision;
|
||||
label: string;
|
||||
style: NonNullable<InteractiveReplyButton["style"]>;
|
||||
command: string;
|
||||
};
|
||||
|
||||
export type ApprovalCommandActionView = {
|
||||
kind: "command";
|
||||
label: string;
|
||||
style: NonNullable<InteractiveReplyButton["style"]>;
|
||||
command: string;
|
||||
};
|
||||
|
||||
export type ApprovalActionView = ApprovalDecisionActionView | ApprovalCommandActionView;
|
||||
|
||||
export type ApprovalMetadataView = {
|
||||
label: string;
|
||||
value: string;
|
||||
|
||||
@@ -416,21 +416,18 @@ describe("exec approval reply helpers", () => {
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "allow-once",
|
||||
label: "Allow Once",
|
||||
style: "success",
|
||||
command: "/approve req-1 allow-once",
|
||||
},
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "allow-always",
|
||||
label: "Allow Always",
|
||||
style: "primary",
|
||||
command: "/approve req-1 allow-always",
|
||||
},
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "deny",
|
||||
label: "Deny",
|
||||
style: "danger",
|
||||
|
||||
@@ -34,18 +34,11 @@ export type ExecApprovalReplyMetadata = {
|
||||
approvalKind: "exec" | "plugin";
|
||||
agentId?: string;
|
||||
allowedDecisions?: readonly ExecApprovalReplyDecision[];
|
||||
actions?: readonly ExecApprovalActionDescriptor[];
|
||||
sessionKey?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
severity?: "info" | "warning" | "critical";
|
||||
toolName?: string;
|
||||
pluginId?: string;
|
||||
};
|
||||
|
||||
export type ExecApprovalActionDescriptor = {
|
||||
kind?: "decision" | "command";
|
||||
decision?: ExecApprovalReplyDecision;
|
||||
decision: ExecApprovalReplyDecision;
|
||||
label: string;
|
||||
style: NonNullable<MessagePresentationButton["style"]>;
|
||||
command: string;
|
||||
@@ -130,7 +123,6 @@ export function buildExecApprovalActionDescriptors(params: {
|
||||
const descriptors: ExecApprovalActionDescriptor[] = [];
|
||||
if (allowedDecisions.includes("allow-once")) {
|
||||
descriptors.push({
|
||||
kind: "decision",
|
||||
decision: "allow-once",
|
||||
label: "Allow Once",
|
||||
style: "success",
|
||||
@@ -142,7 +134,6 @@ export function buildExecApprovalActionDescriptors(params: {
|
||||
}
|
||||
if (allowedDecisions.includes("allow-always")) {
|
||||
descriptors.push({
|
||||
kind: "decision",
|
||||
decision: "allow-always",
|
||||
label: "Allow Always",
|
||||
style: "primary",
|
||||
@@ -154,7 +145,6 @@ export function buildExecApprovalActionDescriptors(params: {
|
||||
}
|
||||
if (allowedDecisions.includes("deny")) {
|
||||
descriptors.push({
|
||||
kind: "decision",
|
||||
decision: "deny",
|
||||
label: "Deny",
|
||||
style: "danger",
|
||||
@@ -318,45 +308,6 @@ function buildFence(text: string, language?: string): string {
|
||||
return `${fence}${languagePrefix}\n${text}\n${fence}`;
|
||||
}
|
||||
|
||||
function normalizeActionDescriptors(raw: unknown): ExecApprovalActionDescriptor[] | undefined {
|
||||
if (!Array.isArray(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
const actions: ExecApprovalActionDescriptor[] = [];
|
||||
for (const value of raw) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
continue;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const label = normalizeOptionalString(record.label);
|
||||
const command = normalizeOptionalString(record.command);
|
||||
const style =
|
||||
record.style === "primary" ||
|
||||
record.style === "secondary" ||
|
||||
record.style === "success" ||
|
||||
record.style === "danger"
|
||||
? record.style
|
||||
: undefined;
|
||||
if (!label || !command || !style) {
|
||||
continue;
|
||||
}
|
||||
const decision =
|
||||
record.decision === "allow-once" ||
|
||||
record.decision === "allow-always" ||
|
||||
record.decision === "deny"
|
||||
? record.decision
|
||||
: undefined;
|
||||
actions.push({
|
||||
kind: record.kind === "command" ? "command" : "decision",
|
||||
label,
|
||||
style,
|
||||
command,
|
||||
...(decision ? { decision } : {}),
|
||||
});
|
||||
}
|
||||
return actions.length > 0 ? actions : undefined;
|
||||
}
|
||||
|
||||
export function getExecApprovalReplyMetadata(
|
||||
payload: ReplyPayload,
|
||||
): ExecApprovalReplyMetadata | null {
|
||||
@@ -381,18 +332,9 @@ export function getExecApprovalReplyMetadata(
|
||||
value === "allow-once" || value === "allow-always" || value === "deny",
|
||||
)
|
||||
: undefined;
|
||||
const actions = normalizeActionDescriptors(record.actions);
|
||||
const agentId = normalizeOptionalString(record.agentId);
|
||||
const sessionKey = normalizeOptionalString(record.sessionKey);
|
||||
const title = normalizeOptionalString(record.title);
|
||||
const description = normalizeOptionalString(record.description);
|
||||
const severity =
|
||||
record.severity === "info" || record.severity === "warning" || record.severity === "critical"
|
||||
? record.severity
|
||||
: undefined;
|
||||
const toolName = normalizeOptionalString(record.toolName);
|
||||
const pluginId = normalizeOptionalString(record.pluginId);
|
||||
const metadata: ExecApprovalReplyMetadata = {
|
||||
return {
|
||||
approvalId,
|
||||
approvalSlug,
|
||||
approvalKind,
|
||||
@@ -400,25 +342,6 @@ export function getExecApprovalReplyMetadata(
|
||||
allowedDecisions,
|
||||
sessionKey,
|
||||
};
|
||||
if (actions) {
|
||||
metadata.actions = actions;
|
||||
}
|
||||
if (title) {
|
||||
metadata.title = title;
|
||||
}
|
||||
if (description) {
|
||||
metadata.description = description;
|
||||
}
|
||||
if (severity) {
|
||||
metadata.severity = severity;
|
||||
}
|
||||
if (toolName) {
|
||||
metadata.toolName = toolName;
|
||||
}
|
||||
if (pluginId) {
|
||||
metadata.pluginId = pluginId;
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function buildExecApprovalPendingReplyPayload(
|
||||
|
||||
@@ -1,109 +1,5 @@
|
||||
import type { InteractiveButtonStyle } from "../interactive/payload.js";
|
||||
import type { ExecApprovalDecision } from "./exec-approvals.js";
|
||||
|
||||
export type PluginApprovalActionKind = "decision" | "command";
|
||||
export type PluginApprovalActionStyle = InteractiveButtonStyle;
|
||||
|
||||
export type PluginApprovalDecisionActionTemplate = {
|
||||
kind: "decision";
|
||||
label: string;
|
||||
style: PluginApprovalActionStyle;
|
||||
decision: ExecApprovalDecision;
|
||||
commandTemplate: string;
|
||||
};
|
||||
|
||||
export type PluginApprovalCommandActionTemplate = {
|
||||
kind: "command";
|
||||
label: string;
|
||||
style: PluginApprovalActionStyle;
|
||||
commandTemplate: string;
|
||||
};
|
||||
|
||||
export type PluginApprovalActionTemplate =
|
||||
| PluginApprovalDecisionActionTemplate
|
||||
| PluginApprovalCommandActionTemplate;
|
||||
|
||||
export type PluginApprovalDecisionActionDescriptor = Omit<
|
||||
PluginApprovalDecisionActionTemplate,
|
||||
"commandTemplate"
|
||||
> & {
|
||||
command: string;
|
||||
};
|
||||
|
||||
export type PluginApprovalCommandActionDescriptor = Omit<
|
||||
PluginApprovalCommandActionTemplate,
|
||||
"commandTemplate"
|
||||
> & {
|
||||
command: string;
|
||||
};
|
||||
|
||||
export type PluginApprovalActionDescriptor =
|
||||
| PluginApprovalDecisionActionDescriptor
|
||||
| PluginApprovalCommandActionDescriptor;
|
||||
|
||||
type PluginApprovalActionBase = {
|
||||
kind: PluginApprovalActionKind;
|
||||
label: string;
|
||||
style: PluginApprovalActionStyle;
|
||||
};
|
||||
|
||||
type NormalizedPluginApprovalActionTemplate =
|
||||
| (PluginApprovalActionBase & {
|
||||
kind: "decision";
|
||||
decision: ExecApprovalDecision;
|
||||
commandTemplate: string;
|
||||
})
|
||||
| (PluginApprovalActionBase & {
|
||||
kind: "command";
|
||||
commandTemplate: string;
|
||||
});
|
||||
|
||||
function isApprovalDecision(value: unknown): value is ExecApprovalDecision {
|
||||
return value === "allow-once" || value === "allow-always" || value === "deny";
|
||||
}
|
||||
|
||||
export function validatePluginApprovalActionTemplates(
|
||||
actions: readonly PluginApprovalActionTemplate[],
|
||||
): string | null {
|
||||
for (const [index, action] of actions.entries()) {
|
||||
const decision = (action as { decision?: unknown }).decision;
|
||||
if (action.kind === "command" && decision !== undefined) {
|
||||
return `actions[${index}] command actions must not include decision`;
|
||||
}
|
||||
if (action.kind === "decision" && !isApprovalDecision(decision)) {
|
||||
return `actions[${index}] decision actions must include a valid decision`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizePluginApprovalActionTemplate(
|
||||
action: PluginApprovalActionTemplate,
|
||||
): NormalizedPluginApprovalActionTemplate | null {
|
||||
const decision = (action as { decision?: unknown }).decision;
|
||||
if (action.kind === "command" && decision !== undefined) {
|
||||
return null;
|
||||
}
|
||||
if (action.kind === "command") {
|
||||
return {
|
||||
kind: "command",
|
||||
label: action.label,
|
||||
style: action.style,
|
||||
commandTemplate: action.commandTemplate,
|
||||
};
|
||||
}
|
||||
if (action.kind === "decision" && isApprovalDecision(decision)) {
|
||||
return {
|
||||
kind: "decision",
|
||||
label: action.label,
|
||||
style: action.style,
|
||||
decision,
|
||||
commandTemplate: action.commandTemplate,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export type PluginApprovalRequestPayload = {
|
||||
pluginId?: string | null;
|
||||
title: string;
|
||||
@@ -112,7 +8,6 @@ export type PluginApprovalRequestPayload = {
|
||||
toolName?: string | null;
|
||||
toolCallId?: string | null;
|
||||
allowedDecisions?: readonly ExecApprovalDecision[] | null;
|
||||
actions?: readonly PluginApprovalActionDescriptor[] | null;
|
||||
agentId?: string | null;
|
||||
sessionKey?: string | null;
|
||||
turnSourceChannel?: string | null;
|
||||
@@ -140,9 +35,6 @@ export const DEFAULT_PLUGIN_APPROVAL_TIMEOUT_MS = 120_000;
|
||||
export const MAX_PLUGIN_APPROVAL_TIMEOUT_MS = 600_000;
|
||||
export const PLUGIN_APPROVAL_TITLE_MAX_LENGTH = 80;
|
||||
export const PLUGIN_APPROVAL_DESCRIPTION_MAX_LENGTH = 256;
|
||||
export const PLUGIN_APPROVAL_ACTION_LABEL_MAX_LENGTH = 40;
|
||||
export const PLUGIN_APPROVAL_ACTION_COMMAND_TEMPLATE_MAX_LENGTH = 200;
|
||||
export const MAX_PLUGIN_APPROVAL_ACTIONS = 6;
|
||||
export const DEFAULT_PLUGIN_APPROVAL_DECISIONS = [
|
||||
"allow-once",
|
||||
"allow-always",
|
||||
@@ -176,45 +68,6 @@ export function resolvePluginApprovalRequestAllowedDecisions(params?: {
|
||||
return explicit.length > 0 ? explicit : DEFAULT_PLUGIN_APPROVAL_DECISIONS;
|
||||
}
|
||||
|
||||
export function expandPluginApprovalActionTemplates(params: {
|
||||
approvalId: string;
|
||||
actions?: readonly PluginApprovalActionTemplate[] | null;
|
||||
}): readonly PluginApprovalActionDescriptor[] | undefined {
|
||||
if (!Array.isArray(params.actions) || params.actions.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const expanded: PluginApprovalActionDescriptor[] = [];
|
||||
for (const rawAction of params.actions) {
|
||||
const action = normalizePluginApprovalActionTemplate(rawAction);
|
||||
if (!action) {
|
||||
continue;
|
||||
}
|
||||
const label = action.label.trim();
|
||||
const command = action.commandTemplate.replaceAll("{id}", params.approvalId).trim();
|
||||
if (!label || !command) {
|
||||
continue;
|
||||
}
|
||||
if (action.kind === "decision") {
|
||||
expanded.push({
|
||||
kind: "decision",
|
||||
label,
|
||||
style: action.style,
|
||||
decision: action.decision,
|
||||
command,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
expanded.push({
|
||||
kind: "command",
|
||||
label,
|
||||
style: action.style,
|
||||
command,
|
||||
});
|
||||
}
|
||||
return expanded.length > 0 ? expanded : undefined;
|
||||
}
|
||||
|
||||
export function buildPluginApprovalRequestMessage(
|
||||
request: PluginApprovalRequest,
|
||||
nowMsValue: number,
|
||||
@@ -237,19 +90,11 @@ export function buildPluginApprovalRequestMessage(
|
||||
lines.push(`ID: ${request.id}`);
|
||||
const expiresIn = Math.max(0, Math.round((request.expiresAtMs - nowMsValue) / 1000));
|
||||
lines.push(`Expires in: ${expiresIn}s`);
|
||||
const actionCommands = request.request.actions
|
||||
?.map((action) => action.command.trim())
|
||||
.filter((command) => command.length > 0);
|
||||
if (actionCommands && actionCommands.length > 0) {
|
||||
lines.push("Reply with one of:");
|
||||
lines.push(actionCommands.join("\n"));
|
||||
} else {
|
||||
lines.push(
|
||||
`Reply with: /approve <id> ${resolvePluginApprovalRequestAllowedDecisions(
|
||||
request.request,
|
||||
).join("|")}`,
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
`Reply with: /approve <id> ${resolvePluginApprovalRequestAllowedDecisions(request.request).join(
|
||||
"|",
|
||||
)}`,
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ describe("plugin activation boundary", () => {
|
||||
expect(isStaticallyChannelConfigured({}, "whatsapp", {})).toBe(false);
|
||||
const staticNormalize = {
|
||||
allowPluginNormalization: false,
|
||||
manifestPlugins: loadPluginManifestRegistryForPluginRegistry().plugins,
|
||||
manifestPlugins: [{ modelIdNormalization: testModelIdNormalization }],
|
||||
};
|
||||
expect(normalizeModelRef("google", "gemini-3.1-pro", staticNormalize)).toEqual({
|
||||
provider: "google",
|
||||
|
||||
@@ -205,21 +205,18 @@ describe("plugin-sdk/approval-reaction-runtime", () => {
|
||||
sessionKey: "main:signal:+15555550123",
|
||||
actions: [
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "allow-once",
|
||||
label: "Allow Once",
|
||||
style: "success",
|
||||
command: "/approve exec-approval-123 allow-once",
|
||||
},
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "allow-always",
|
||||
label: "Allow Always",
|
||||
style: "primary",
|
||||
command: "/approve exec-approval-123 allow-always",
|
||||
},
|
||||
{
|
||||
kind: "decision",
|
||||
decision: "deny",
|
||||
label: "Deny",
|
||||
style: "danger",
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* New plugin code should use the focused approval runtime/reply subpaths.
|
||||
*/
|
||||
import { sanitizeForPromptLiteral } from "../agents/sanitize-for-prompt.js";
|
||||
import { matchesApprovalRequestFilters } from "../infra/approval-request-filters.js";
|
||||
import { formatApprovalDisplayPath } from "../infra/approval-display-paths.js";
|
||||
import { matchesApprovalRequestFilters } from "../infra/approval-request-filters.js";
|
||||
import { buildPendingApprovalView } from "../infra/approval-view-model.js";
|
||||
import type { ApprovalRequest, PendingApprovalView } from "../infra/approval-view-model.types.js";
|
||||
import {
|
||||
@@ -295,19 +295,13 @@ function buildManualInstructionSection(params: {
|
||||
return lines;
|
||||
}
|
||||
|
||||
function listDecisionActions(actions: PendingApprovalView["actions"]): ExecApprovalReplyDecision[] {
|
||||
return normalizeDecisionList(
|
||||
actions.flatMap((action) => (action.kind === "decision" ? [action.decision] : [])),
|
||||
);
|
||||
}
|
||||
|
||||
function buildApprovalReactionPromptText(params: {
|
||||
view: PendingApprovalView;
|
||||
nowMs: number;
|
||||
reactionHint: string | null;
|
||||
}): string {
|
||||
const { view } = params;
|
||||
const allowedDecisions = listDecisionActions(view.actions);
|
||||
const allowedDecisions = normalizeDecisionList(view.actions.map((action) => action.decision));
|
||||
const sections: string[] = [];
|
||||
if (view.approvalKind === "exec") {
|
||||
const header = ["Exec approval required", `ID: ${view.approvalId}`];
|
||||
@@ -410,7 +404,9 @@ export function buildApprovalPendingPromptPayload(params: {
|
||||
view: PendingApprovalView;
|
||||
nowMs: number;
|
||||
}): ApprovalReactionPromptPayload {
|
||||
const allowedDecisions = listDecisionActions(params.view.actions);
|
||||
const allowedDecisions = normalizeDecisionList(
|
||||
params.view.actions.map((action) => action.decision),
|
||||
);
|
||||
const reactionBindings = listApprovalReactionBindings({ allowedDecisions });
|
||||
const text = buildApprovalReactionPromptText({
|
||||
view: params.view,
|
||||
|
||||
@@ -94,10 +94,8 @@ describe("plugin-sdk/approval-renderers", () => {
|
||||
approvalKind: "plugin",
|
||||
approvalSlug: "custom-slug",
|
||||
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
||||
description: "Needs approval",
|
||||
sessionKey: undefined,
|
||||
state: "pending",
|
||||
title: "Sensitive action",
|
||||
},
|
||||
telegram: {
|
||||
quoteText: "quoted",
|
||||
@@ -147,90 +145,8 @@ describe("plugin-sdk/approval-renderers", () => {
|
||||
approvalKind: "plugin",
|
||||
approvalSlug: "plugin-a",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
description: "Needs approval",
|
||||
sessionKey: undefined,
|
||||
state: "pending",
|
||||
title: "Sensitive action",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "builds pending payloads with plugin-provided actions",
|
||||
payload: buildApprovalPendingReplyPayload({
|
||||
approvalKind: "plugin",
|
||||
approvalId: "plugin:approval-123",
|
||||
approvalSlug: "plugin:a",
|
||||
text: "AgentKit approval required",
|
||||
title: "World ID approval",
|
||||
description: "Approve in World app",
|
||||
severity: "critical",
|
||||
toolName: "protected-request",
|
||||
pluginId: "agentkit",
|
||||
actions: [
|
||||
{
|
||||
kind: "command",
|
||||
label: "Open AgentKit",
|
||||
style: "primary",
|
||||
command: "/agentkit approve plugin:approval-123",
|
||||
},
|
||||
{
|
||||
kind: "decision",
|
||||
label: "Deny",
|
||||
style: "danger",
|
||||
decision: "deny",
|
||||
command: "/agentkit deny plugin:approval-123",
|
||||
},
|
||||
],
|
||||
}),
|
||||
textExpected: (text: string) => expect(text).toBe("AgentKit approval required"),
|
||||
presentationExpected: {
|
||||
blocks: [
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [
|
||||
{
|
||||
label: "Open AgentKit",
|
||||
value: "/agentkit approve plugin:approval-123",
|
||||
style: "primary",
|
||||
},
|
||||
{
|
||||
label: "Deny",
|
||||
value: "/agentkit deny plugin:approval-123",
|
||||
style: "danger",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
channelDataExpected: {
|
||||
execApproval: {
|
||||
agentId: undefined,
|
||||
approvalId: "plugin:approval-123",
|
||||
approvalKind: "plugin",
|
||||
approvalSlug: "plugin:a",
|
||||
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
||||
actions: [
|
||||
{
|
||||
kind: "command",
|
||||
label: "Open AgentKit",
|
||||
style: "primary",
|
||||
command: "/agentkit approve plugin:approval-123",
|
||||
},
|
||||
{
|
||||
kind: "decision",
|
||||
label: "Deny",
|
||||
style: "danger",
|
||||
decision: "deny",
|
||||
command: "/agentkit deny plugin:approval-123",
|
||||
},
|
||||
],
|
||||
description: "Approve in World app",
|
||||
pluginId: "agentkit",
|
||||
sessionKey: undefined,
|
||||
severity: "critical",
|
||||
state: "pending",
|
||||
title: "World ID approval",
|
||||
toolName: "protected-request",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
buildApprovalPresentation,
|
||||
buildApprovalPresentationFromActionDescriptors,
|
||||
type ExecApprovalActionDescriptor,
|
||||
type ExecApprovalReplyDecision,
|
||||
} from "../infra/exec-approval-reply.js";
|
||||
import {
|
||||
@@ -24,29 +22,16 @@ export function buildApprovalPendingReplyPayload(params: {
|
||||
text: string;
|
||||
agentId?: string | null;
|
||||
allowedDecisions?: readonly ExecApprovalReplyDecision[];
|
||||
actions?: readonly ExecApprovalActionDescriptor[];
|
||||
sessionKey?: string | null;
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
severity?: "info" | "warning" | "critical" | null;
|
||||
toolName?: string | null;
|
||||
pluginId?: string | null;
|
||||
channelData?: Record<string, unknown>;
|
||||
}): ReplyPayload {
|
||||
const allowedDecisions = params.allowedDecisions ?? DEFAULT_ALLOWED_DECISIONS;
|
||||
const actions = params.actions?.length ? params.actions : undefined;
|
||||
const title = normalizeOptionalString(params.title);
|
||||
const description = normalizeOptionalString(params.description);
|
||||
const toolName = normalizeOptionalString(params.toolName);
|
||||
const pluginId = normalizeOptionalString(params.pluginId);
|
||||
return {
|
||||
text: params.text,
|
||||
presentation: actions
|
||||
? buildApprovalPresentationFromActionDescriptors(actions)
|
||||
: buildApprovalPresentation({
|
||||
approvalId: params.approvalId,
|
||||
allowedDecisions,
|
||||
}),
|
||||
presentation: buildApprovalPresentation({
|
||||
approvalId: params.approvalId,
|
||||
allowedDecisions,
|
||||
}),
|
||||
channelData: {
|
||||
execApproval: {
|
||||
approvalId: params.approvalId,
|
||||
@@ -54,13 +39,7 @@ export function buildApprovalPendingReplyPayload(params: {
|
||||
approvalKind: params.approvalKind ?? "exec",
|
||||
agentId: normalizeOptionalString(params.agentId),
|
||||
allowedDecisions,
|
||||
...(actions ? { actions } : {}),
|
||||
sessionKey: normalizeOptionalString(params.sessionKey),
|
||||
...(title ? { title } : {}),
|
||||
...(description ? { description } : {}),
|
||||
...(params.severity ? { severity: params.severity } : {}),
|
||||
...(toolName ? { toolName } : {}),
|
||||
...(pluginId ? { pluginId } : {}),
|
||||
state: "pending",
|
||||
},
|
||||
...params.channelData,
|
||||
@@ -104,14 +83,6 @@ export function buildPluginApprovalPendingReplyPayload(params: {
|
||||
allowedDecisions:
|
||||
params.allowedDecisions ??
|
||||
resolvePluginApprovalRequestAllowedDecisions(params.request.request),
|
||||
actions: params.request.request.actions ?? undefined,
|
||||
agentId: params.request.request.agentId,
|
||||
sessionKey: params.request.request.sessionKey,
|
||||
title: params.request.request.title,
|
||||
description: params.request.request.description,
|
||||
severity: params.request.request.severity,
|
||||
toolName: params.request.request.toolName,
|
||||
pluginId: params.request.request.pluginId,
|
||||
channelData: params.channelData,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -492,22 +492,6 @@ export type PluginHookBeforeToolCallResult = {
|
||||
timeoutMs?: number;
|
||||
timeoutBehavior?: "allow" | "deny";
|
||||
allowedDecisions?: Array<"allow-once" | "allow-always" | "deny">;
|
||||
actions?: Array<
|
||||
| {
|
||||
kind: "decision";
|
||||
label: string;
|
||||
style: "primary" | "secondary" | "success" | "danger";
|
||||
decision: "allow-once" | "allow-always" | "deny";
|
||||
commandTemplate: string;
|
||||
}
|
||||
| {
|
||||
kind: "command";
|
||||
label: string;
|
||||
style: "primary" | "secondary" | "success" | "danger";
|
||||
commandTemplate: string;
|
||||
}
|
||||
>;
|
||||
keepPendingWithoutRoute?: boolean;
|
||||
pluginId?: string;
|
||||
onResolution?: (decision: PluginApprovalResolution) => Promise<void> | void;
|
||||
};
|
||||
|
||||
@@ -4454,28 +4454,6 @@ td.data-table-key-col {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.exec-approval-command-action {
|
||||
flex: 1 1 100%;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.exec-approval-command-action span {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.exec-approval-command-action code {
|
||||
font-family: var(--mono);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Agents
|
||||
=========================================== */
|
||||
|
||||
@@ -1441,15 +1441,6 @@ describe("connectGateway", () => {
|
||||
pluginId: "sage",
|
||||
agentId: "agent-1",
|
||||
sessionKey: "main",
|
||||
allowedDecisions: ["deny"],
|
||||
actions: [
|
||||
{
|
||||
kind: "command",
|
||||
label: "Verify once",
|
||||
style: "primary",
|
||||
command: "/agentkit approve plugin-approval-1 allow-once",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1457,15 +1448,6 @@ describe("connectGateway", () => {
|
||||
expect(host.execApprovalQueue).toHaveLength(1);
|
||||
expect(host.execApprovalQueue[0]?.id).toBe("plugin-approval-1");
|
||||
expect((host.execApprovalQueue[0] as { kind: string }).kind).toBe("plugin");
|
||||
expect(host.execApprovalQueue[0]?.allowedDecisions).toEqual(["deny"]);
|
||||
expect(host.execApprovalQueue[0]?.actions).toEqual([
|
||||
{
|
||||
kind: "command",
|
||||
label: "Verify once",
|
||||
style: "primary",
|
||||
command: "/agentkit approve plugin-approval-1 allow-once",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("routes plugin.approval.resolved to remove from execApprovalQueue", () => {
|
||||
|
||||
@@ -89,22 +89,6 @@ describe("parsePluginApprovalRequested", () => {
|
||||
pluginId: "sage",
|
||||
agentId: "agent-1",
|
||||
sessionKey: "sess-1",
|
||||
allowedDecisions: ["deny"],
|
||||
actions: [
|
||||
{
|
||||
kind: "command",
|
||||
label: "Verify with World",
|
||||
style: "primary",
|
||||
command: "/agentkit approve plugin-1 allow-once",
|
||||
},
|
||||
{
|
||||
kind: "decision",
|
||||
label: "Deny",
|
||||
style: "danger",
|
||||
decision: "deny",
|
||||
command: "/approve plugin-1 deny",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -115,22 +99,6 @@ describe("parsePluginApprovalRequested", () => {
|
||||
expect(result?.pluginDescription).toBe("chmod 777 script.sh modifies file permissions");
|
||||
expect(result?.pluginSeverity).toBe("high");
|
||||
expect(result?.pluginId).toBe("sage");
|
||||
expect(result?.allowedDecisions).toEqual(["deny"]);
|
||||
expect(result?.actions).toEqual([
|
||||
{
|
||||
kind: "command",
|
||||
label: "Verify with World",
|
||||
style: "primary",
|
||||
command: "/agentkit approve plugin-1 allow-once",
|
||||
},
|
||||
{
|
||||
kind: "decision",
|
||||
label: "Deny",
|
||||
style: "danger",
|
||||
decision: "deny",
|
||||
command: "/approve plugin-1 deny",
|
||||
},
|
||||
]);
|
||||
expect(result?.request.command).toBe("Dangerous command detected");
|
||||
expect(result?.request.agentId).toBe("agent-1");
|
||||
expect(result?.request.sessionKey).toBe("sess-1");
|
||||
|
||||
@@ -17,24 +17,6 @@ export type ExecApprovalRequestPayload = {
|
||||
};
|
||||
|
||||
export type ExecApprovalDecision = "allow-once" | "allow-always" | "deny";
|
||||
export type ApprovalActionStyle = "primary" | "secondary" | "success" | "danger";
|
||||
|
||||
export type ExecApprovalDecisionAction = {
|
||||
kind: "decision";
|
||||
label: string;
|
||||
style: ApprovalActionStyle;
|
||||
decision: ExecApprovalDecision;
|
||||
command?: string | null;
|
||||
};
|
||||
|
||||
export type ExecApprovalCommandAction = {
|
||||
kind: "command";
|
||||
label: string;
|
||||
style: ApprovalActionStyle;
|
||||
command: string;
|
||||
};
|
||||
|
||||
export type ExecApprovalAction = ExecApprovalDecisionAction | ExecApprovalCommandAction;
|
||||
|
||||
export type ExecApprovalRequest = {
|
||||
id: string;
|
||||
@@ -44,8 +26,6 @@ export type ExecApprovalRequest = {
|
||||
pluginDescription?: string | null;
|
||||
pluginSeverity?: string | null;
|
||||
pluginId?: string | null;
|
||||
allowedDecisions?: readonly ExecApprovalDecision[];
|
||||
actions?: readonly ExecApprovalAction[];
|
||||
createdAtMs: number;
|
||||
expiresAtMs: number;
|
||||
};
|
||||
@@ -74,69 +54,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function parseApprovalDecision(value: unknown): ExecApprovalDecision | null {
|
||||
return value === "allow-once" || value === "allow-always" || value === "deny" ? value : null;
|
||||
}
|
||||
|
||||
function parseApprovalStyle(value: unknown): ApprovalActionStyle {
|
||||
return value === "primary" || value === "success" || value === "danger" ? value : "secondary";
|
||||
}
|
||||
|
||||
function parseAllowedDecisions(value: unknown): ExecApprovalDecision[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const decisions: ExecApprovalDecision[] = [];
|
||||
for (const item of value) {
|
||||
const decision = parseApprovalDecision(item);
|
||||
if (decision && !decisions.includes(decision)) {
|
||||
decisions.push(decision);
|
||||
}
|
||||
}
|
||||
return decisions.length > 0 ? decisions : undefined;
|
||||
}
|
||||
|
||||
function parsePluginApprovalActions(value: unknown): ExecApprovalAction[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const actions: ExecApprovalAction[] = [];
|
||||
for (const item of value) {
|
||||
if (!isRecord(item)) {
|
||||
continue;
|
||||
}
|
||||
const kind = item.kind;
|
||||
const label = normalizeOptionalString(item.label) ?? "";
|
||||
const command = normalizeOptionalString(item.command) ?? "";
|
||||
if (!label || !command) {
|
||||
continue;
|
||||
}
|
||||
if (kind === "decision") {
|
||||
const decision = parseApprovalDecision(item.decision);
|
||||
if (!decision) {
|
||||
continue;
|
||||
}
|
||||
actions.push({
|
||||
kind: "decision",
|
||||
label,
|
||||
style: parseApprovalStyle(item.style),
|
||||
decision,
|
||||
command,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (kind === "command") {
|
||||
actions.push({
|
||||
kind: "command",
|
||||
label,
|
||||
style: parseApprovalStyle(item.style),
|
||||
command,
|
||||
});
|
||||
}
|
||||
}
|
||||
return actions.length > 0 ? actions : undefined;
|
||||
}
|
||||
|
||||
function parseCommandSpans(
|
||||
value: unknown,
|
||||
commandLength: number,
|
||||
@@ -174,6 +91,17 @@ function parseCommandSpans(
|
||||
return spans.length > 0 ? spans : undefined;
|
||||
}
|
||||
|
||||
function parseAllowedDecisions(value: unknown): ExecApprovalDecision[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const decisions = value.filter(
|
||||
(decision): decision is ExecApprovalDecision =>
|
||||
decision === "allow-once" || decision === "allow-always" || decision === "deny",
|
||||
);
|
||||
return decisions.length > 0 ? decisions : undefined;
|
||||
}
|
||||
|
||||
export function parseExecApprovalRequested(payload: unknown): ExecApprovalRequest | null {
|
||||
if (!isRecord(payload)) {
|
||||
return null;
|
||||
@@ -250,8 +178,6 @@ export function parsePluginApprovalRequested(payload: unknown): ExecApprovalRequ
|
||||
const description = typeof request.description === "string" ? request.description : null;
|
||||
const severity = typeof request.severity === "string" ? request.severity : null;
|
||||
const pluginId = typeof request.pluginId === "string" ? request.pluginId : null;
|
||||
const allowedDecisions = parseAllowedDecisions(request.allowedDecisions);
|
||||
const actions = parsePluginApprovalActions(request.actions);
|
||||
|
||||
return {
|
||||
id,
|
||||
@@ -266,8 +192,6 @@ export function parsePluginApprovalRequested(payload: unknown): ExecApprovalRequ
|
||||
pluginDescription: description,
|
||||
pluginSeverity: severity,
|
||||
pluginId,
|
||||
...(allowedDecisions ? { allowedDecisions } : {}),
|
||||
...(actions ? { actions } : {}),
|
||||
createdAtMs,
|
||||
expiresAtMs,
|
||||
};
|
||||
|
||||
@@ -226,53 +226,6 @@ describe("approval and confirmation modals", () => {
|
||||
expect(handleExecApprovalDecision).toHaveBeenCalledWith("deny");
|
||||
});
|
||||
|
||||
it("renders plugin command actions and only available decision buttons", async () => {
|
||||
const handleExecApprovalDecision = vi.fn(async () => undefined);
|
||||
const request: ExecApprovalRequest = {
|
||||
id: "plugin-approval-1",
|
||||
kind: "plugin",
|
||||
request: {
|
||||
command: "World proof required",
|
||||
agentId: "main",
|
||||
sessionKey: "main",
|
||||
},
|
||||
pluginTitle: "World proof required",
|
||||
pluginDescription: "Verify with World before exec runs.",
|
||||
pluginSeverity: "warning",
|
||||
pluginId: "agentkit",
|
||||
allowedDecisions: ["deny"],
|
||||
actions: [
|
||||
{
|
||||
kind: "command",
|
||||
label: "Verify once",
|
||||
style: "primary",
|
||||
command: "/agentkit approve plugin-approval-1 allow-once",
|
||||
},
|
||||
],
|
||||
createdAtMs: Date.now(),
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
};
|
||||
|
||||
render(
|
||||
renderExecApprovalPrompt(
|
||||
createExecState({ execApprovalQueue: [request], handleExecApprovalDecision }),
|
||||
),
|
||||
container,
|
||||
);
|
||||
await getRenderedDialog();
|
||||
|
||||
expect(container.querySelector(".exec-approval-command-action")?.textContent).toContain(
|
||||
"/agentkit approve plugin-approval-1 allow-once",
|
||||
);
|
||||
const buttons = Array.from(container.querySelectorAll(".exec-approval-actions button")).map(
|
||||
(button) => button.textContent?.trim(),
|
||||
);
|
||||
expect(buttons).toEqual(["Deny"]);
|
||||
|
||||
container.querySelector<HTMLButtonElement>(".exec-approval-actions button")?.click();
|
||||
expect(handleExecApprovalDecision).toHaveBeenCalledWith("deny");
|
||||
});
|
||||
|
||||
it("does not dispatch an extra exec decision from Escape while busy", async () => {
|
||||
const handleExecApprovalDecision = vi.fn(async () => undefined);
|
||||
render(
|
||||
|
||||
@@ -4,13 +4,12 @@ import { t } from "../../i18n/index.ts";
|
||||
import type { AppViewState } from "../app-view-state.ts";
|
||||
import "../components/modal-dialog.ts";
|
||||
import type {
|
||||
ExecApprovalAction,
|
||||
ExecApprovalDecision,
|
||||
ExecApprovalRequest,
|
||||
ExecApprovalRequestPayload,
|
||||
} from "../controllers/exec-approval.ts";
|
||||
|
||||
const DEFAULT_APPROVAL_DECISIONS = [
|
||||
const DEFAULT_EXEC_APPROVAL_DECISIONS = [
|
||||
"allow-once",
|
||||
"allow-always",
|
||||
"deny",
|
||||
@@ -115,107 +114,49 @@ ${active.pluginDescription}</pre
|
||||
`;
|
||||
}
|
||||
|
||||
function decisionLabel(decision: ExecApprovalDecision): string {
|
||||
if (decision === "allow-once") {
|
||||
return t("execApproval.allowOnce");
|
||||
}
|
||||
if (decision === "allow-always") {
|
||||
return t("execApproval.alwaysAllow");
|
||||
function approvalDecisionLabel(decision: ExecApprovalDecision): string {
|
||||
switch (decision) {
|
||||
case "allow-once":
|
||||
return t("execApproval.allowOnce");
|
||||
case "allow-always":
|
||||
return t("execApproval.alwaysAllow");
|
||||
case "deny":
|
||||
return t("execApproval.deny");
|
||||
}
|
||||
return t("execApproval.deny");
|
||||
}
|
||||
|
||||
function decisionStyle(decision: ExecApprovalDecision): ExecApprovalAction["style"] {
|
||||
if (decision === "allow-once") {
|
||||
return "primary";
|
||||
function approvalDecisionClass(decision: ExecApprovalDecision): string {
|
||||
switch (decision) {
|
||||
case "allow-once":
|
||||
return "btn primary";
|
||||
case "allow-always":
|
||||
return "btn";
|
||||
case "deny":
|
||||
return "btn danger";
|
||||
}
|
||||
if (decision === "deny") {
|
||||
return "danger";
|
||||
}
|
||||
return "secondary";
|
||||
return "btn danger";
|
||||
}
|
||||
|
||||
function buttonClass(style: ExecApprovalAction["style"]) {
|
||||
if (style === "danger") {
|
||||
return "btn danger";
|
||||
}
|
||||
if (style === "primary" || style === "success") {
|
||||
return "btn primary";
|
||||
}
|
||||
return "btn";
|
||||
}
|
||||
|
||||
function resolveExecApprovalDecisions(
|
||||
active: ExecApprovalRequest,
|
||||
): readonly ExecApprovalDecision[] {
|
||||
function resolveApprovalDecisions(active: ExecApprovalRequest): readonly ExecApprovalDecision[] {
|
||||
if (active.request.allowedDecisions?.length) {
|
||||
return active.request.allowedDecisions;
|
||||
}
|
||||
if (active.request.ask === "always") {
|
||||
if (active.kind === "exec" && active.request.ask === "always") {
|
||||
return ["allow-once", "deny"];
|
||||
}
|
||||
return DEFAULT_APPROVAL_DECISIONS;
|
||||
}
|
||||
|
||||
function resolveApprovalActions(active: ExecApprovalRequest): ExecApprovalAction[] {
|
||||
if (active.kind === "exec") {
|
||||
return resolveExecApprovalDecisions(active).map((decision) => ({
|
||||
kind: "decision",
|
||||
decision,
|
||||
label: decisionLabel(decision),
|
||||
style: decisionStyle(decision),
|
||||
}));
|
||||
}
|
||||
const actions = [...(active.actions ?? [])];
|
||||
const representedDecisions = new Set(
|
||||
actions.flatMap((action) => (action.kind === "decision" ? [action.decision] : [])),
|
||||
);
|
||||
const allowedDecisions =
|
||||
active.allowedDecisions ?? active.request.allowedDecisions ?? DEFAULT_APPROVAL_DECISIONS;
|
||||
for (const decision of allowedDecisions) {
|
||||
if (representedDecisions.has(decision)) {
|
||||
continue;
|
||||
}
|
||||
actions.push({
|
||||
kind: "decision",
|
||||
decision,
|
||||
label: decisionLabel(decision),
|
||||
style: decisionStyle(decision),
|
||||
});
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
function resolveDecisionValues(actions: readonly ExecApprovalAction[]): ExecApprovalDecision[] {
|
||||
return actions.flatMap((action) => (action.kind === "decision" ? [action.decision] : []));
|
||||
return DEFAULT_EXEC_APPROVAL_DECISIONS;
|
||||
}
|
||||
|
||||
function renderUnavailableDecisionWarning(
|
||||
active: ExecApprovalRequest,
|
||||
actions: readonly ExecApprovalAction[],
|
||||
decisions: readonly ExecApprovalDecision[],
|
||||
) {
|
||||
const decisions = resolveDecisionValues(actions);
|
||||
return active.kind !== "exec" || decisions.includes("allow-always")
|
||||
? nothing
|
||||
: html`<div class="exec-approval-warning">${t("execApproval.allowAlwaysUnavailable")}</div>`;
|
||||
}
|
||||
|
||||
function renderApprovalAction(action: ExecApprovalAction, state: AppViewState) {
|
||||
if (action.kind === "command") {
|
||||
return html`<div class="exec-approval-command-action">
|
||||
<span>${action.label}</span>
|
||||
<code>${action.command}</code>
|
||||
</div>`;
|
||||
}
|
||||
return html`<button
|
||||
class=${buttonClass(action.style)}
|
||||
?disabled=${state.execApprovalBusy}
|
||||
@click=${() => state.handleExecApprovalDecision(action.decision)}
|
||||
>
|
||||
${action.label}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
export function renderExecApprovalPrompt(state: AppViewState) {
|
||||
const active = state.execApprovalQueue[0];
|
||||
if (!active) {
|
||||
@@ -234,8 +175,7 @@ export function renderExecApprovalPrompt(state: AppViewState) {
|
||||
: t("execApproval.execApprovalNeeded");
|
||||
const titleId = "exec-approval-title";
|
||||
const descriptionId = "exec-approval-description";
|
||||
const actions = resolveApprovalActions(active);
|
||||
const decisions = resolveDecisionValues(actions);
|
||||
const decisions = resolveApprovalDecisions(active);
|
||||
const handleCancel = () => {
|
||||
if (!state.execApprovalBusy && decisions.includes("deny")) {
|
||||
void state.handleExecApprovalDecision("deny");
|
||||
@@ -256,12 +196,22 @@ export function renderExecApprovalPrompt(state: AppViewState) {
|
||||
: nothing}
|
||||
</div>
|
||||
${isPlugin ? renderPluginBody(active) : renderExecBody(request)}
|
||||
${renderUnavailableDecisionWarning(active, actions)}
|
||||
${renderUnavailableDecisionWarning(active, decisions)}
|
||||
${state.execApprovalError
|
||||
? html`<div class="exec-approval-error">${state.execApprovalError}</div>`
|
||||
: nothing}
|
||||
<div class="exec-approval-actions">
|
||||
${actions.map((action) => renderApprovalAction(action, state))}
|
||||
${decisions.map(
|
||||
(decision) => html`
|
||||
<button
|
||||
class=${approvalDecisionClass(decision)}
|
||||
?disabled=${state.execApprovalBusy}
|
||||
@click=${() => state.handleExecApprovalDecision(decision)}
|
||||
>
|
||||
${approvalDecisionLabel(decision)}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</openclaw-modal-dialog>
|
||||
|
||||
Reference in New Issue
Block a user