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:
Kevin Lin
2026-05-27 14:48:06 -07:00
committed by GitHub
parent dfcf211232
commit bb752c2b47
43 changed files with 127 additions and 1926 deletions
@@ -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
+4 -73
View File
@@ -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) {
-31
View File
@@ -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");
});
});
+1 -29
View File
@@ -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,
+11 -115
View File
@@ -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", () => {
+10 -49
View File
@@ -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;
-33
View File
@@ -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",
},
]);
});
});
+10 -70
View File
@@ -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,
};
}
+1 -11
View File
@@ -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;
-3
View File
@@ -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",
+2 -79
View File
@@ -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(
+5 -160
View File
@@ -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");
}
+1 -1
View File
@@ -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",
+5 -9
View File
@@ -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,
-84
View File
@@ -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",
},
},
},
+4 -33
View File
@@ -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,
});
}
-16
View File
@@ -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;
};
-22
View File
@@ -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
=========================================== */
-18
View File
@@ -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");
+11 -87
View File
@@ -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,
};
-47
View File
@@ -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(
+35 -85
View File
@@ -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>