Skip to content

Commit f63b826

Browse files
authored
Merge c6c170b into 968aa51
2 parents 968aa51 + c6c170b commit f63b826

15 files changed

Lines changed: 229 additions & 18 deletions

docs/plugins/sdk-channel-plugins.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,13 +149,14 @@ Most channel plugins do not need approval-specific code.
149149
- `ChannelPlugin.approvals` is removed. Put approval delivery/native/render/auth facts on `approvalCapability`.
150150
- `plugin.auth` is login/logout only; core no longer reads approval auth hooks from that object.
151151
- `approvalCapability.authorizeActorAction` and `approvalCapability.getActionAvailabilityState` are the canonical approval-auth seam.
152-
- Use `approvalCapability.getActionAvailabilityState` for same-chat approval auth availability.
152+
- Use `approvalCapability.getActionAvailabilityState` for same-chat approval auth availability. Keep configured approvers available for `/approve` even when native delivery is disabled; use native initiating-surface state for delivery/setup guidance instead.
153153
- If your channel exposes native exec approvals, use `approvalCapability.getExecInitiatingSurfaceState` for the initiating-surface/native-client state when it differs from same-chat approval auth. Core uses that exec-specific hook to distinguish `enabled` vs `disabled`, decide whether the initiating channel supports native exec approvals, and include the channel in native-client fallback guidance. `createApproverRestrictedNativeApprovalCapability(...)` fills this in for the common case.
154154
- Use `outbound.shouldSuppressLocalPayloadPrompt` or `outbound.beforeDeliverPayload` for channel-specific payload lifecycle behavior such as hiding duplicate local approval prompts or sending typing indicators before delivery.
155155
- Use `approvalCapability.delivery` only for native approval routing or fallback suppression.
156156
- Use `approvalCapability.nativeRuntime` for channel-owned native approval facts. Keep it lazy on hot channel entrypoints with `createLazyChannelApprovalNativeRuntimeAdapter(...)`, which can import your runtime module on demand while still letting core assemble the approval lifecycle.
157157
- Use `approvalCapability.render` only when a channel truly needs custom approval payloads instead of the shared renderer.
158158
- Use `approvalCapability.describeExecApprovalSetup` when the channel wants the disabled-path reply to explain the exact config knobs needed to enable native exec approvals. The hook receives `{ channel, channelLabel, accountId }`; named-account channels should render account-scoped paths such as `channels.<channel>.accounts.<id>.execApprovals.*` instead of top-level defaults.
159+
- Use `approvalCapability.describePluginApprovalSetup` when plugin approval failure guidance is safe to show for plugin approval no-route and timeout failures. `createApproverRestrictedNativeApprovalCapability(...)` does not infer this from `describeExecApprovalSetup`; pass the same helper explicitly only when plugin and exec approvals truly use the same native setup.
159160
- If a channel can infer stable owner-like DM identities from existing config, use `createResolvedApproverActionAuthAdapter` from `openclaw/plugin-sdk/approval-runtime` to restrict same-chat `/approve` without adding approval-specific core logic.
160161
- If custom approval auth intentionally allows only same-chat fallback, return `markImplicitSameChatApprovalAuthorization({ authorized: true })` from `openclaw/plugin-sdk/approval-auth-runtime`; otherwise core treats the result as explicit approver authorization.
161162
- If a channel-owned native callback resolves approvals directly, use `isImplicitSameChatApprovalAuthorization(...)` before resolving so implicit fallback still goes through the channel's normal actor authorization.

extensions/slack/src/approval-native.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,16 @@ describe("slack native approval adapter", () => {
162162
expect(text).not.toContain("`channels.slack.execApprovals.approvers`");
163163
});
164164

165+
it("does not reuse exec setup copy for plugin approval setup", () => {
166+
expect(
167+
slackApprovalCapability.describeExecApprovalSetup?.({
168+
channel: "slack",
169+
channelLabel: "Slack",
170+
}),
171+
).toContain("`channels.slack.execApprovals.approvers`");
172+
expect(slackApprovalCapability.describePluginApprovalSetup).toBeUndefined();
173+
});
174+
165175
it("resolves origin targets from slack turn source", async () => {
166176
const target = await resolveExecOriginTarget();
167177

extensions/telegram/src/approval-native.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,16 +82,19 @@ const resolveTelegramApproverDmTargets = createChannelApproverDmTargetResolver({
8282
mapApprover: (approver) => ({ to: approver }),
8383
});
8484

85+
function describeTelegramExecApprovalSetup({ accountId }: { accountId?: string | null }) {
86+
const prefix =
87+
accountId && accountId !== "default"
88+
? `channels.telegram.accounts.${accountId}`
89+
: "channels.telegram";
90+
return `Approve it from the Web UI or terminal UI for now. Telegram supports native exec approvals for this account. Configure \`${prefix}.execApprovals.approvers\` or \`commands.ownerAllowFrom\`; leave \`${prefix}.execApprovals.enabled\` unset/\`auto\` or set it to \`true\`.`;
91+
}
92+
8593
const telegramNativeApprovalCapability = createApproverRestrictedNativeApprovalCapability({
8694
channel: "telegram",
8795
channelLabel: "Telegram",
88-
describeExecApprovalSetup: ({ accountId }: { accountId?: string | null }) => {
89-
const prefix =
90-
accountId && accountId !== "default"
91-
? `channels.telegram.accounts.${accountId}`
92-
: "channels.telegram";
93-
return `Approve it from the Web UI or terminal UI for now. Telegram supports native exec approvals for this account. Configure \`${prefix}.execApprovals.approvers\` or \`commands.ownerAllowFrom\`; leave \`${prefix}.execApprovals.enabled\` unset/\`auto\` or set it to \`true\`.`;
94-
},
96+
describeExecApprovalSetup: describeTelegramExecApprovalSetup,
97+
describePluginApprovalSetup: describeTelegramExecApprovalSetup,
9598
listAccountIds: listTelegramAccountIds,
9699
hasApprovers: ({ cfg, accountId }) =>
97100
getTelegramExecApprovalApprovers({ cfg, accountId }).length > 0,

src/agents/agent-tools.before-tool-call.e2e.test.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
2323
import { setActivePluginRegistry } from "../plugins/runtime.js";
2424
import { setPluginToolMeta } from "../plugins/tools.js";
2525
import { createCanonicalFixtureSkill } from "../skills/test-support/test-helpers.js";
26+
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
2627
import {
2728
getBeforeToolCallPolicyDiagnosticState,
2829
runBeforeToolCallHook,
@@ -1042,6 +1043,26 @@ describe("before_tool_call requireApproval handling", () => {
10421043
}
10431044
}
10441045

1046+
function registerTelegramPluginApprovalSetup(): void {
1047+
setActivePluginRegistry(
1048+
createTestRegistry([
1049+
{
1050+
pluginId: "telegram",
1051+
source: "test",
1052+
plugin: {
1053+
...createChannelTestPluginBase({ id: "telegram", label: "Telegram" }),
1054+
approvalCapability: {
1055+
native: {},
1056+
getActionAvailabilityState: () => ({ kind: "enabled" as const }),
1057+
getExecInitiatingSurfaceState: () => ({ kind: "disabled" as const }),
1058+
describePluginApprovalSetup: () => "Configure Telegram native approval setup.",
1059+
},
1060+
},
1061+
},
1062+
]),
1063+
);
1064+
}
1065+
10451066
beforeEach(() => {
10461067
resetDiagnosticSessionStateForTest();
10471068
resetDiagnosticEventsForTest();
@@ -1492,25 +1513,39 @@ describe("before_tool_call requireApproval handling", () => {
14921513
expect(result).toHaveProperty("reason", "Denied by user");
14931514
});
14941515

1495-
it("blocks on timeout with default deny behavior", async () => {
1516+
it("blocks turn-source plugin approval timeouts with setup guidance", async () => {
1517+
registerTelegramPluginApprovalSetup();
14961518
hookRunner.runBeforeToolCall.mockResolvedValue({
14971519
requireApproval: {
14981520
title: "Timeout test",
14991521
description: "Will time out",
15001522
},
15011523
});
15021524

1503-
mockCallGateway.mockResolvedValueOnce({ id: "server-id-3", status: "accepted" });
1525+
mockCallGateway.mockResolvedValueOnce({
1526+
id: "server-id-3",
1527+
status: "accepted",
1528+
deliveryRoute: "turn-source",
1529+
});
15041530
mockCallGateway.mockResolvedValueOnce({ id: "server-id-3", decision: null });
15051531

15061532
const result = await runBeforeToolCallHook({
15071533
toolName: "bash",
15081534
params: {},
1509-
ctx: { agentId: "main", sessionKey: "main" },
1535+
ctx: {
1536+
agentId: "main",
1537+
sessionKey: "main",
1538+
turnSourceChannel: "telegram",
1539+
turnSourceTo: "-100123456789",
1540+
turnSourceAccountId: "default",
1541+
},
15101542
});
15111543

15121544
expect(result.blocked).toBe(true);
1513-
expect(result).toHaveProperty("reason", "Approval timed out");
1545+
expect(result).toHaveProperty(
1546+
"reason",
1547+
"Approval timed out\n\nConfigure Telegram native approval setup.",
1548+
);
15141549
});
15151550

15161551
it("allows on timeout when timeoutBehavior is allow and preserves hook params", async () => {

src/agents/agent-tools.before-tool-call.ts

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ import {
3030
freezeDiagnosticTraceContext,
3131
type DiagnosticTraceContext,
3232
} from "../infra/diagnostic-trace-context.js";
33+
import {
34+
describeNativePluginApprovalClientSetup,
35+
resolveApprovalInitiatingSurfaceState,
36+
} from "../infra/exec-approval-surface.js";
3337
import {
3438
DEFAULT_PLUGIN_APPROVAL_TIMEOUT_MS,
3539
MAX_PLUGIN_APPROVAL_TIMEOUT_MS,
@@ -643,6 +647,43 @@ function notifyPluginApprovalResolution(
643647
}
644648
}
645649

650+
function buildPluginApprovalFailureReason(params: {
651+
fallbackReason: string;
652+
ctx?: HookContext;
653+
}): string {
654+
const turnSourceChannel = params.ctx?.turnSourceChannel;
655+
if (!turnSourceChannel?.trim()) {
656+
return params.fallbackReason;
657+
}
658+
const nativePluginSurface = resolveApprovalInitiatingSurfaceState({
659+
channel: turnSourceChannel,
660+
accountId: params.ctx?.turnSourceAccountId,
661+
cfg: params.ctx?.config,
662+
approvalKind: "plugin",
663+
});
664+
const setupText = describeNativePluginApprovalClientSetup({
665+
channel: nativePluginSurface.channel,
666+
channelLabel: nativePluginSurface.channelLabel,
667+
accountId: nativePluginSurface.accountId,
668+
});
669+
if (!setupText) {
670+
return params.fallbackReason;
671+
}
672+
const nativeDeliverySurface =
673+
nativePluginSurface.kind === "disabled"
674+
? nativePluginSurface
675+
: resolveApprovalInitiatingSurfaceState({
676+
channel: turnSourceChannel,
677+
accountId: params.ctx?.turnSourceAccountId,
678+
cfg: params.ctx?.config,
679+
approvalKind: "exec",
680+
});
681+
if (nativeDeliverySurface.kind !== "disabled") {
682+
return params.fallbackReason;
683+
}
684+
return `${params.fallbackReason}\n\n${setupText}`;
685+
}
686+
646687
async function requestPluginToolApproval(params: {
647688
approval: PluginApprovalRequest;
648689
toolName: string;
@@ -660,6 +701,7 @@ async function requestPluginToolApproval(params: {
660701
id?: string;
661702
status?: string;
662703
decision?: string | null;
704+
deliveryRoute?: string;
663705
} = await callGatewayTool(
664706
"plugin.approval.request",
665707
// Buffer beyond the approval timeout so the gateway can clean up
@@ -705,7 +747,10 @@ async function requestPluginToolApproval(params: {
705747
blocked: true,
706748
kind: "failure",
707749
deniedReason: "plugin-approval",
708-
reason: "Plugin approval unavailable (no approval route)",
750+
reason: buildPluginApprovalFailureReason({
751+
fallbackReason: "Plugin approval unavailable (no approval route)",
752+
ctx: params.ctx,
753+
}),
709754
params: params.baseParams,
710755
};
711756
}
@@ -779,11 +824,18 @@ async function requestPluginToolApproval(params: {
779824
approvalResolution: resolution,
780825
};
781826
}
827+
const timeoutReason =
828+
requestResult?.deliveryRoute === "turn-source"
829+
? buildPluginApprovalFailureReason({
830+
fallbackReason: "Approval timed out",
831+
ctx: params.ctx,
832+
})
833+
: "Approval timed out";
782834
return {
783835
blocked: true,
784836
kind: "failure",
785837
deniedReason: "plugin-approval",
786-
reason: "Approval timed out",
838+
reason: timeoutReason,
787839
params: params.baseParams,
788840
};
789841
} catch (err) {

src/auto-reply/reply/commands-approve.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -441,7 +441,7 @@ describe("handleApproveCommand", () => {
441441

442442
function createTelegramApproveCfg(
443443
execApprovals: {
444-
enabled: true;
444+
enabled: boolean;
445445
approvers: string[];
446446
target: "dm";
447447
} | null = { enabled: true, approvers: ["123"], target: "dm" },
@@ -544,6 +544,28 @@ describe("handleApproveCommand", () => {
544544
expectApprovalResolverCall({ method: "exec.approval.resolve", id: "abc12345" });
545545
});
546546

547+
it("accepts forwarded Telegram plugin approvals from approvers when native delivery is disabled", async () => {
548+
const params = buildApproveParams(
549+
"/approve plugin:abc12345 allow-once",
550+
createTelegramApproveCfg({ enabled: false, approvers: ["123"], target: "dm" }),
551+
{
552+
Provider: "telegram",
553+
Surface: "telegram",
554+
SenderId: "123",
555+
},
556+
);
557+
params.command.isAuthorizedSender = false;
558+
resolveApprovalOverGatewayMock.mockResolvedValue(undefined);
559+
560+
const result = await handleApproveCommand(params, true);
561+
expect(result?.shouldContinue).toBe(false);
562+
expect(result?.reply?.text).toContain("Approval allow-once submitted");
563+
expectApprovalResolverCall({
564+
method: "plugin.approval.resolve",
565+
id: "plugin:abc12345",
566+
});
567+
});
568+
547569
it("honors the configured default account for omitted-account /approve auth", async () => {
548570
setActivePluginRegistry(
549571
createTestRegistry([

src/channels/plugins/approvals.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,18 +56,21 @@ describe("resolveChannelApprovalAdapter", () => {
5656
const delivery = { hasConfiguredDmRoute: vi.fn() };
5757
const nativeRuntime = createNativeRuntimeStub();
5858
const describeExecApprovalSetup = vi.fn();
59+
const describePluginApprovalSetup = vi.fn();
5960

6061
expect(
6162
resolveChannelApprovalAdapter({
6263
approvalCapability: {
6364
describeExecApprovalSetup,
65+
describePluginApprovalSetup,
6466
delivery,
6567
nativeRuntime,
6668
authorizeActorAction: vi.fn(),
6769
},
6870
}),
6971
).toEqual({
7072
describeExecApprovalSetup,
73+
describePluginApprovalSetup,
7174
delivery,
7275
nativeRuntime,
7376
render: undefined,

src/channels/plugins/approvals.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export function resolveChannelApprovalAdapter(
3636
}
3737
return {
3838
describeExecApprovalSetup: capability.describeExecApprovalSetup,
39+
describePluginApprovalSetup: capability.describePluginApprovalSetup,
3940
delivery: capability.delivery,
4041
nativeRuntime: capability.nativeRuntime,
4142
render: capability.render,

src/channels/plugins/types.adapters.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,11 @@ export type ChannelApprovalAdapter = {
636636
channelLabel: string;
637637
accountId?: string;
638638
}) => string | null | undefined;
639+
describePluginApprovalSetup?: (params: {
640+
channel: string;
641+
channelLabel: string;
642+
accountId?: string;
643+
}) => string | null | undefined;
639644
};
640645

641646
export type ChannelApprovalCapability = ChannelApprovalAdapter & {

src/gateway/server-methods/approval-shared.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ describe("handlePendingApprovalRequest", () => {
299299
).toBe(false);
300300
});
301301

302-
it("does not resolve turn-source routes when approval clients are already available", async () => {
302+
it("reports an active approval client instead of the manual turn-source route", async () => {
303303
const manager = new ExecApprovalManager();
304304
const record = manager.create(
305305
{
@@ -334,6 +334,15 @@ describe("handlePendingApprovalRequest", () => {
334334

335335
await Promise.resolve();
336336
expect(hasApprovalTurnSourceRouteMock).not.toHaveBeenCalled();
337+
expect(respond).toHaveBeenCalledWith(
338+
true,
339+
expect.objectContaining({
340+
id: "approval-with-client",
341+
status: "accepted",
342+
deliveryRoute: "approval-client",
343+
}),
344+
undefined,
345+
);
337346

338347
expect(manager.resolve(record.id, "allow-once")).toBe(true);
339348
await requestPromise;

0 commit comments

Comments
 (0)