Telegram: exec approvals for OpenCode/Codex (#37233)

Merged via squash.

Prepared head SHA: f243379094
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Reviewed-by: @huntharo
This commit is contained in:
Harold Hunt
2026-03-09 23:04:35 -04:00
committed by GitHub
parent 9432a8bb3f
commit de49a8b72c
78 changed files with 4058 additions and 524 deletions

View File

@@ -42,6 +42,7 @@ Docs: https://docs.openclaw.ai
- Plugins/context-engine model auth: expose `runtime.modelAuth` and plugin-sdk auth helpers so plugins can resolve provider/model API keys through the normal auth pipeline. (#41090) thanks @xinhuagu.
- CLI/memory teardown: close cached memory search/index managers in the one-shot CLI shutdown path so watcher-backed memory caches no longer keep completed CLI runs alive after output finishes. (#40389) thanks @Julbarth.
- Tools/web search: treat Brave `llm-context` grounding snippets as plain strings so `web_search` no longer returns empty snippet arrays in LLM Context mode. (#41387) thanks @zheliu2.
- Telegram/exec approvals: reject `/approve` commands aimed at other bots, keep deterministic approval prompts visible when tool-result delivery fails, and stop resolved exact IDs from matching other pending approvals by prefix. (#37233) Thanks @huntharo.
## 2026.3.8

View File

@@ -760,6 +760,34 @@ openclaw message poll --channel telegram --target -1001234567890:topic:42 \
- `channels.telegram.actions.poll=false` disables Telegram poll creation while leaving regular sends enabled
</Accordion>
<Accordion title="Exec approvals in Telegram">
Telegram supports exec approvals in approver DMs and can optionally post approval prompts in the originating chat or topic.
Config path:
- `channels.telegram.execApprovals.enabled`
- `channels.telegram.execApprovals.approvers`
- `channels.telegram.execApprovals.target` (`dm` | `channel` | `both`, default: `dm`)
- `agentFilter`, `sessionFilter`
Approvers must be numeric Telegram user IDs. When `enabled` is false or `approvers` is empty, Telegram does not act as an exec approval client. Approval requests fall back to other configured approval routes or the exec approval fallback policy.
Delivery rules:
- `target: "dm"` sends approval prompts only to configured approver DMs
- `target: "channel"` sends the prompt back to the originating Telegram chat/topic
- `target: "both"` sends to approver DMs and the originating chat/topic
Only configured approvers can approve or deny. Non-approvers cannot use `/approve` and cannot use Telegram approval buttons.
Channel delivery shows the command text in the chat, so only enable `channel` or `both` in trusted groups/topics. When the prompt lands in a forum topic, OpenClaw preserves the topic for both the approval prompt and the post-approval follow-up.
Inline approval buttons also depend on `channels.telegram.capabilities.inlineButtons` allowing the target surface (`dm`, `group`, or `all`).
Related docs: [Exec approvals](/tools/exec-approvals)
</Accordion>
</AccordionGroup>
## Troubleshooting
@@ -859,10 +887,16 @@ Primary reference:
- `channels.telegram.groups.<id>.enabled`: disable the group when `false`.
- `channels.telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (group fields + topic-only `agentId`).
- `channels.telegram.groups.<id>.topics.<threadId>.agentId`: route this topic to a specific agent (overrides group-level and binding routing).
- `channels.telegram.groups.<id>.topics.<threadId>.groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`).
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
- top-level `bindings[]` with `type: "acp"` and canonical topic id `chatId:topic:topicId` in `match.peer.id`: persistent ACP topic binding fields (see [ACP Agents](/tools/acp-agents#channel-specific-settings)).
- `channels.telegram.direct.<id>.topics.<threadId>.agentId`: route DM topics to a specific agent (same behavior as forum topics).
- `channels.telegram.groups.<id>.topics.<threadId>.groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`).
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
- top-level `bindings[]` with `type: "acp"` and canonical topic id `chatId:topic:topicId` in `match.peer.id`: persistent ACP topic binding fields (see [ACP Agents](/tools/acp-agents#channel-specific-settings)).
- `channels.telegram.direct.<id>.topics.<threadId>.agentId`: route DM topics to a specific agent (same behavior as forum topics).
- `channels.telegram.execApprovals.enabled`: enable Telegram as a chat-based exec approval client for this account.
- `channels.telegram.execApprovals.approvers`: Telegram user IDs allowed to approve or deny exec requests. Required when exec approvals are enabled.
- `channels.telegram.execApprovals.target`: `dm | channel | both` (default: `dm`). `channel` and `both` preserve the originating Telegram topic when present.
- `channels.telegram.execApprovals.agentFilter`: optional agent ID filter for forwarded approval prompts.
- `channels.telegram.execApprovals.sessionFilter`: optional session key filter (substring or regex) for forwarded approval prompts.
- `channels.telegram.accounts.<account>.execApprovals`: per-account override for Telegram exec approval routing and approver authorization.
- `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist).
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
- `channels.telegram.commands.nativeSkills`: enable/disable Telegram native skills commands.
@@ -894,6 +928,7 @@ Telegram-specific high-signal fields:
- startup/auth: `enabled`, `botToken`, `tokenFile`, `accounts.*`
- access control: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `groups.*.topics.*`, top-level `bindings[]` (`type: "acp"`)
- exec approvals: `execApprovals`, `accounts.*.execApprovals`
- command/menu: `commands.native`, `commands.nativeSkills`, `customCommands`
- threading/replies: `replyToMode`
- streaming: `streaming` (preview), `blockStreaming`

View File

@@ -309,6 +309,32 @@ Reply in chat:
/approve <id> deny
```
### Built-in chat approval clients
Discord and Telegram can also act as explicit exec approval clients with channel-specific config.
- Discord: `channels.discord.execApprovals.*`
- Telegram: `channels.telegram.execApprovals.*`
These clients are opt-in. If a channel does not have exec approvals enabled, OpenClaw does not treat
that channel as an approval surface just because the conversation happened there.
Shared behavior:
- only configured approvers can approve or deny
- the requester does not need to be an approver
- when channel delivery is enabled, approval prompts include the command text
- if no operator UI or configured approval client can accept the request, the prompt falls back to `askFallback`
Telegram defaults to approver DMs (`target: "dm"`). You can switch to `channel` or `both` when you
want approval prompts to appear in the originating Telegram chat/topic as well. For Telegram forum
topics, OpenClaw preserves the topic for the approval prompt and the post-approval follow-up.
See:
- [Discord](/channels/discord#exec-approvals-in-discord)
- [Telegram](/channels/telegram#exec-approvals-in-telegram)
### macOS IPC flow
```

View File

@@ -179,6 +179,41 @@ describe("telegramPlugin duplicate token guard", () => {
expect(result).toMatchObject({ channel: "telegram", messageId: "tg-1" });
});
it("preserves buttons for outbound text payload sends", async () => {
const sendMessageTelegram = vi.fn(async () => ({ messageId: "tg-2" }));
setTelegramRuntime({
channel: {
telegram: {
sendMessageTelegram,
},
},
} as unknown as PluginRuntime);
const result = await telegramPlugin.outbound!.sendPayload!({
cfg: createCfg(),
to: "12345",
text: "",
payload: {
text: "Approval required",
channelData: {
telegram: {
buttons: [[{ text: "Allow Once", callback_data: "/approve abc allow-once" }]],
},
},
},
accountId: "ops",
});
expect(sendMessageTelegram).toHaveBeenCalledWith(
"12345",
"Approval required",
expect.objectContaining({
buttons: [[{ text: "Allow Once", callback_data: "/approve abc allow-once" }]],
}),
);
expect(result).toMatchObject({ channel: "telegram", messageId: "tg-2" });
});
it("ignores accounts with missing tokens during duplicate-token checks", async () => {
const cfg = createCfg();
cfg.channels!.telegram!.accounts!.ops = {} as never;

View File

@@ -91,6 +91,10 @@ const telegramMessageActions: ChannelMessageActionAdapter = {
},
};
type TelegramInlineButtons = ReadonlyArray<
ReadonlyArray<{ text: string; callback_data: string; style?: "danger" | "success" | "primary" }>
>;
const telegramConfigAccessors = createScopedAccountConfigAccessors({
resolveAccount: ({ cfg, accountId }) => resolveTelegramAccount({ cfg, accountId }),
resolveAllowFrom: (account: ResolvedTelegramAccount) => account.config.allowFrom,
@@ -317,6 +321,62 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
chunkerMode: "markdown",
textChunkLimit: 4000,
pollMaxOptions: 10,
sendPayload: async ({
cfg,
to,
payload,
mediaLocalRoots,
accountId,
deps,
replyToId,
threadId,
silent,
}) => {
const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram;
const replyToMessageId = parseTelegramReplyToMessageId(replyToId);
const messageThreadId = parseTelegramThreadId(threadId);
const telegramData = payload.channelData?.telegram as
| { buttons?: TelegramInlineButtons; quoteText?: string }
| undefined;
const quoteText =
typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined;
const text = payload.text ?? "";
const mediaUrls = payload.mediaUrls?.length
? payload.mediaUrls
: payload.mediaUrl
? [payload.mediaUrl]
: [];
const baseOpts = {
verbose: false,
cfg,
mediaLocalRoots,
messageThreadId,
replyToMessageId,
quoteText,
accountId: accountId ?? undefined,
silent: silent ?? undefined,
};
if (mediaUrls.length === 0) {
const result = await send(to, text, {
...baseOpts,
buttons: telegramData?.buttons,
});
return { channel: "telegram", ...result };
}
let finalResult: Awaited<ReturnType<typeof send>> | undefined;
for (let i = 0; i < mediaUrls.length; i += 1) {
const mediaUrl = mediaUrls[i];
const isFirst = i === 0;
finalResult = await send(to, isFirst ? text : "", {
...baseOpts,
mediaUrl,
...(isFirst ? { buttons: telegramData?.buttons } : {}),
});
}
return { channel: "telegram", ...(finalResult ?? { messageId: "unknown", chatId: to }) };
},
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, silent }) => {
const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram;
const replyToMessageId = parseTelegramReplyToMessageId(replyToId);

View File

@@ -0,0 +1,61 @@
import { callGatewayTool } from "./tools/gateway.js";
type ExecApprovalFollowupParams = {
approvalId: string;
sessionKey?: string;
turnSourceChannel?: string;
turnSourceTo?: string;
turnSourceAccountId?: string;
turnSourceThreadId?: string | number;
resultText: string;
};
export function buildExecApprovalFollowupPrompt(resultText: string): string {
return [
"An async command the user already approved has completed.",
"Do not run the command again.",
"",
"Exact completion details:",
resultText.trim(),
"",
"Reply to the user in a helpful way.",
"If it succeeded, share the relevant output.",
"If it failed, explain what went wrong.",
].join("\n");
}
export async function sendExecApprovalFollowup(
params: ExecApprovalFollowupParams,
): Promise<boolean> {
const sessionKey = params.sessionKey?.trim();
const resultText = params.resultText.trim();
if (!sessionKey || !resultText) {
return false;
}
const channel = params.turnSourceChannel?.trim();
const to = params.turnSourceTo?.trim();
const threadId =
params.turnSourceThreadId != null && params.turnSourceThreadId !== ""
? String(params.turnSourceThreadId)
: undefined;
await callGatewayTool(
"agent",
{ timeoutMs: 60_000 },
{
sessionKey,
message: buildExecApprovalFollowupPrompt(resultText),
deliver: true,
bestEffortDeliver: true,
channel: channel && to ? channel : undefined,
to: channel && to ? to : undefined,
accountId: channel && to ? params.turnSourceAccountId?.trim() || undefined : undefined,
threadId: channel && to ? threadId : undefined,
idempotencyKey: `exec-approval-followup:${params.approvalId}`,
},
{ expectFinal: true },
);
return true;
}

View File

@@ -1,4 +1,10 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { loadConfig } from "../config/config.js";
import { buildExecApprovalUnavailableReplyPayload } from "../infra/exec-approval-reply.js";
import {
hasConfiguredExecApprovalDmRoute,
resolveExecApprovalInitiatingSurfaceState,
} from "../infra/exec-approval-surface.js";
import {
addAllowlistEntry,
type ExecAsk,
@@ -13,6 +19,7 @@ import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js";
import type { SafeBinProfile } from "../infra/exec-safe-bin-policy.js";
import { logInfo } from "../logger.js";
import { markBackgrounded, tail } from "./bash-process-registry.js";
import { sendExecApprovalFollowup } from "./bash-tools.exec-approval-followup.js";
import {
buildExecApprovalRequesterContext,
buildExecApprovalTurnSourceContext,
@@ -25,9 +32,9 @@ import {
resolveExecHostApprovalContext,
} from "./bash-tools.exec-host-shared.js";
import {
buildApprovalPendingMessage,
DEFAULT_NOTIFY_TAIL_CHARS,
createApprovalSlug,
emitExecSystemEvent,
normalizeNotifyOutput,
runExecProcess,
} from "./bash-tools.exec-runtime.js";
@@ -141,8 +148,6 @@ export async function processGatewayAllowlist(
const {
approvalId,
approvalSlug,
contextKey,
noticeSeconds,
warningText,
expiresAtMs: defaultExpiresAtMs,
preResolvedDecision: defaultPreResolvedDecision,
@@ -174,19 +179,37 @@ export async function processGatewayAllowlist(
});
expiresAtMs = registration.expiresAtMs;
preResolvedDecision = registration.finalDecision;
const initiatingSurface = resolveExecApprovalInitiatingSurfaceState({
channel: params.turnSourceChannel,
accountId: params.turnSourceAccountId,
});
const cfg = loadConfig();
const sentApproverDms =
(initiatingSurface.kind === "disabled" || initiatingSurface.kind === "unsupported") &&
hasConfiguredExecApprovalDmRoute(cfg);
const unavailableReason =
preResolvedDecision === null
? "no-approval-route"
: initiatingSurface.kind === "disabled"
? "initiating-platform-disabled"
: initiatingSurface.kind === "unsupported"
? "initiating-platform-unsupported"
: null;
void (async () => {
const decision = await resolveApprovalDecisionOrUndefined({
approvalId,
preResolvedDecision,
onFailure: () =>
emitExecSystemEvent(
`Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`,
{
sessionKey: params.notifySessionKey,
contextKey,
},
),
void sendExecApprovalFollowup({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
resultText: `Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`,
}),
});
if (decision === undefined) {
return;
@@ -230,13 +253,15 @@ export async function processGatewayAllowlist(
}
if (deniedReason) {
emitExecSystemEvent(
`Exec denied (gateway id=${approvalId}, ${deniedReason}): ${params.command}`,
{
sessionKey: params.notifySessionKey,
contextKey,
},
);
await sendExecApprovalFollowup({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
resultText: `Exec denied (gateway id=${approvalId}, ${deniedReason}): ${params.command}`,
}).catch(() => {});
return;
}
@@ -262,32 +287,21 @@ export async function processGatewayAllowlist(
timeoutSec: effectiveTimeout,
});
} catch {
emitExecSystemEvent(
`Exec denied (gateway id=${approvalId}, spawn-failed): ${params.command}`,
{
sessionKey: params.notifySessionKey,
contextKey,
},
);
await sendExecApprovalFollowup({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
resultText: `Exec denied (gateway id=${approvalId}, spawn-failed): ${params.command}`,
}).catch(() => {});
return;
}
markBackgrounded(run.session);
let runningTimer: NodeJS.Timeout | null = null;
if (params.approvalRunningNoticeMs > 0) {
runningTimer = setTimeout(() => {
emitExecSystemEvent(
`Exec running (gateway id=${approvalId}, session=${run?.session.id}, >${noticeSeconds}s): ${params.command}`,
{ sessionKey: params.notifySessionKey, contextKey },
);
}, params.approvalRunningNoticeMs);
}
const outcome = await run.promise;
if (runningTimer) {
clearTimeout(runningTimer);
}
const output = normalizeNotifyOutput(
tail(outcome.aggregated || "", DEFAULT_NOTIFY_TAIL_CHARS),
);
@@ -295,7 +309,15 @@ export async function processGatewayAllowlist(
const summary = output
? `Exec finished (gateway id=${approvalId}, session=${run.session.id}, ${exitLabel})\n${output}`
: `Exec finished (gateway id=${approvalId}, session=${run.session.id}, ${exitLabel})`;
emitExecSystemEvent(summary, { sessionKey: params.notifySessionKey, contextKey });
await sendExecApprovalFollowup({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
resultText: summary,
}).catch(() => {});
})();
return {
@@ -304,19 +326,45 @@ export async function processGatewayAllowlist(
{
type: "text",
text:
`${warningText}Approval required (id ${approvalSlug}). ` +
"Approve to run; updates will arrive after completion.",
unavailableReason !== null
? (buildExecApprovalUnavailableReplyPayload({
warningText,
reason: unavailableReason,
channelLabel: initiatingSurface.channelLabel,
sentApproverDms,
}).text ?? "")
: buildApprovalPendingMessage({
warningText,
approvalSlug,
approvalId,
command: params.command,
cwd: params.workdir,
host: "gateway",
}),
},
],
details: {
status: "approval-pending",
approvalId,
approvalSlug,
expiresAtMs,
host: "gateway",
command: params.command,
cwd: params.workdir,
},
details:
unavailableReason !== null
? ({
status: "approval-unavailable",
reason: unavailableReason,
channelLabel: initiatingSurface.channelLabel,
sentApproverDms,
host: "gateway",
command: params.command,
cwd: params.workdir,
warningText,
} satisfies ExecToolDetails)
: ({
status: "approval-pending",
approvalId,
approvalSlug,
expiresAtMs,
host: "gateway",
command: params.command,
cwd: params.workdir,
warningText,
} satisfies ExecToolDetails),
},
};
}

View File

@@ -1,5 +1,11 @@
import crypto from "node:crypto";
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { loadConfig } from "../config/config.js";
import { buildExecApprovalUnavailableReplyPayload } from "../infra/exec-approval-reply.js";
import {
hasConfiguredExecApprovalDmRoute,
resolveExecApprovalInitiatingSurfaceState,
} from "../infra/exec-approval-surface.js";
import {
type ExecApprovalsFile,
type ExecAsk,
@@ -12,6 +18,7 @@ import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js";
import { buildNodeShellCommand } from "../infra/node-shell.js";
import { parsePreparedSystemRunPayload } from "../infra/system-run-approval-context.js";
import { logInfo } from "../logger.js";
import { sendExecApprovalFollowup } from "./bash-tools.exec-approval-followup.js";
import {
buildExecApprovalRequesterContext,
buildExecApprovalTurnSourceContext,
@@ -23,7 +30,12 @@ import {
resolveApprovalDecisionOrUndefined,
resolveExecHostApprovalContext,
} from "./bash-tools.exec-host-shared.js";
import { createApprovalSlug, emitExecSystemEvent } from "./bash-tools.exec-runtime.js";
import {
buildApprovalPendingMessage,
DEFAULT_NOTIFY_TAIL_CHARS,
createApprovalSlug,
normalizeNotifyOutput,
} from "./bash-tools.exec-runtime.js";
import type { ExecToolDetails } from "./bash-tools.exec-types.js";
import { callGatewayTool } from "./tools/gateway.js";
import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js";
@@ -187,6 +199,7 @@ export async function executeNodeHostCommand(
approvedByAsk: boolean,
approvalDecision: "allow-once" | "allow-always" | null,
runId?: string,
suppressNotifyOnExit?: boolean,
) =>
({
nodeId,
@@ -202,6 +215,7 @@ export async function executeNodeHostCommand(
approved: approvedByAsk,
approvalDecision: approvalDecision ?? undefined,
runId: runId ?? undefined,
suppressNotifyOnExit: suppressNotifyOnExit === true ? true : undefined,
},
idempotencyKey: crypto.randomUUID(),
}) satisfies Record<string, unknown>;
@@ -210,8 +224,6 @@ export async function executeNodeHostCommand(
const {
approvalId,
approvalSlug,
contextKey,
noticeSeconds,
warningText,
expiresAtMs: defaultExpiresAtMs,
preResolvedDecision: defaultPreResolvedDecision,
@@ -243,16 +255,37 @@ export async function executeNodeHostCommand(
});
expiresAtMs = registration.expiresAtMs;
preResolvedDecision = registration.finalDecision;
const initiatingSurface = resolveExecApprovalInitiatingSurfaceState({
channel: params.turnSourceChannel,
accountId: params.turnSourceAccountId,
});
const cfg = loadConfig();
const sentApproverDms =
(initiatingSurface.kind === "disabled" || initiatingSurface.kind === "unsupported") &&
hasConfiguredExecApprovalDmRoute(cfg);
const unavailableReason =
preResolvedDecision === null
? "no-approval-route"
: initiatingSurface.kind === "disabled"
? "initiating-platform-disabled"
: initiatingSurface.kind === "unsupported"
? "initiating-platform-unsupported"
: null;
void (async () => {
const decision = await resolveApprovalDecisionOrUndefined({
approvalId,
preResolvedDecision,
onFailure: () =>
emitExecSystemEvent(
`Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`,
{ sessionKey: params.notifySessionKey, contextKey },
),
void sendExecApprovalFollowup({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
resultText: `Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`,
}),
});
if (decision === undefined) {
return;
@@ -278,44 +311,67 @@ export async function executeNodeHostCommand(
}
if (deniedReason) {
emitExecSystemEvent(
`Exec denied (node=${nodeId} id=${approvalId}, ${deniedReason}): ${params.command}`,
{
sessionKey: params.notifySessionKey,
contextKey,
},
);
await sendExecApprovalFollowup({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
resultText: `Exec denied (node=${nodeId} id=${approvalId}, ${deniedReason}): ${params.command}`,
}).catch(() => {});
return;
}
let runningTimer: NodeJS.Timeout | null = null;
if (params.approvalRunningNoticeMs > 0) {
runningTimer = setTimeout(() => {
emitExecSystemEvent(
`Exec running (node=${nodeId} id=${approvalId}, >${noticeSeconds}s): ${params.command}`,
{ sessionKey: params.notifySessionKey, contextKey },
);
}, params.approvalRunningNoticeMs);
}
try {
await callGatewayTool(
const raw = await callGatewayTool<{
payload?: {
stdout?: string;
stderr?: string;
error?: string | null;
exitCode?: number | null;
timedOut?: boolean;
};
}>(
"node.invoke",
{ timeoutMs: invokeTimeoutMs },
buildInvokeParams(approvedByAsk, approvalDecision, approvalId),
buildInvokeParams(approvedByAsk, approvalDecision, approvalId, true),
);
const payload =
raw?.payload && typeof raw.payload === "object"
? (raw.payload as {
stdout?: string;
stderr?: string;
error?: string | null;
exitCode?: number | null;
timedOut?: boolean;
})
: {};
const combined = [payload.stdout, payload.stderr, payload.error].filter(Boolean).join("\n");
const output = normalizeNotifyOutput(combined.slice(-DEFAULT_NOTIFY_TAIL_CHARS));
const exitLabel = payload.timedOut ? "timeout" : `code ${payload.exitCode ?? "?"}`;
const summary = output
? `Exec finished (node=${nodeId} id=${approvalId}, ${exitLabel})\n${output}`
: `Exec finished (node=${nodeId} id=${approvalId}, ${exitLabel})`;
await sendExecApprovalFollowup({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
resultText: summary,
}).catch(() => {});
} catch {
emitExecSystemEvent(
`Exec denied (node=${nodeId} id=${approvalId}, invoke-failed): ${params.command}`,
{
sessionKey: params.notifySessionKey,
contextKey,
},
);
} finally {
if (runningTimer) {
clearTimeout(runningTimer);
}
await sendExecApprovalFollowup({
approvalId,
sessionKey: params.notifySessionKey,
turnSourceChannel: params.turnSourceChannel,
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
resultText: `Exec denied (node=${nodeId} id=${approvalId}, invoke-failed): ${params.command}`,
}).catch(() => {});
}
})();
@@ -324,20 +380,48 @@ export async function executeNodeHostCommand(
{
type: "text",
text:
`${warningText}Approval required (id ${approvalSlug}). ` +
"Approve to run; updates will arrive after completion.",
unavailableReason !== null
? (buildExecApprovalUnavailableReplyPayload({
warningText,
reason: unavailableReason,
channelLabel: initiatingSurface.channelLabel,
sentApproverDms,
}).text ?? "")
: buildApprovalPendingMessage({
warningText,
approvalSlug,
approvalId,
command: prepared.cmdText,
cwd: runCwd,
host: "node",
nodeId,
}),
},
],
details: {
status: "approval-pending",
approvalId,
approvalSlug,
expiresAtMs,
host: "node",
command: params.command,
cwd: params.workdir,
nodeId,
},
details:
unavailableReason !== null
? ({
status: "approval-unavailable",
reason: unavailableReason,
channelLabel: initiatingSurface.channelLabel,
sentApproverDms,
host: "node",
command: params.command,
cwd: params.workdir,
nodeId,
warningText,
} satisfies ExecToolDetails)
: ({
status: "approval-pending",
approvalId,
approvalSlug,
expiresAtMs,
host: "node",
command: params.command,
cwd: params.workdir,
nodeId,
warningText,
} satisfies ExecToolDetails),
};
}

View File

@@ -230,6 +230,40 @@ export function createApprovalSlug(id: string) {
return id.slice(0, APPROVAL_SLUG_LENGTH);
}
export function buildApprovalPendingMessage(params: {
warningText?: string;
approvalSlug: string;
approvalId: string;
command: string;
cwd: string;
host: "gateway" | "node";
nodeId?: string;
}) {
let fence = "```";
while (params.command.includes(fence)) {
fence += "`";
}
const commandBlock = `${fence}sh\n${params.command}\n${fence}`;
const lines: string[] = [];
const warningText = params.warningText?.trim();
if (warningText) {
lines.push(warningText, "");
}
lines.push(`Approval required (id ${params.approvalSlug}, full ${params.approvalId}).`);
lines.push(`Host: ${params.host}`);
if (params.nodeId) {
lines.push(`Node: ${params.nodeId}`);
}
lines.push(`CWD: ${params.cwd}`);
lines.push("Command:");
lines.push(commandBlock);
lines.push("Mode: foreground (interactive approvals available).");
lines.push("Background mode requires pre-approved policy (allow-always or ask=off).");
lines.push(`Reply with: /approve ${params.approvalSlug} allow-once|allow-always|deny`);
lines.push("If the short code is ambiguous, use the full id in /approve.");
return lines.join("\n");
}
export function resolveApprovalRunningNoticeMs(value?: number) {
if (typeof value !== "number" || !Number.isFinite(value)) {
return DEFAULT_APPROVAL_RUNNING_NOTICE_MS;

View File

@@ -60,4 +60,19 @@ export type ExecToolDetails =
command: string;
cwd?: string;
nodeId?: string;
warningText?: string;
}
| {
status: "approval-unavailable";
reason:
| "initiating-platform-disabled"
| "initiating-platform-unsupported"
| "no-approval-route";
channelLabel?: string;
sentApproverDms?: boolean;
host: ExecHost;
command: string;
cwd?: string;
nodeId?: string;
warningText?: string;
};

View File

@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { clearConfigCache } from "../config/config.js";
import { buildSystemRunPreparePayload } from "../test-utils/system-run-prepare-payload.js";
vi.mock("./tools/gateway.js", () => ({
@@ -63,6 +64,7 @@ describe("exec approvals", () => {
afterEach(() => {
vi.resetAllMocks();
clearConfigCache();
if (previousHome === undefined) {
delete process.env.HOME;
} else {
@@ -77,6 +79,7 @@ describe("exec approvals", () => {
it("reuses approval id as the node runId", async () => {
let invokeParams: unknown;
let agentParams: unknown;
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
if (method === "exec.approval.request") {
@@ -85,6 +88,10 @@ describe("exec approvals", () => {
if (method === "exec.approval.waitDecision") {
return { decision: "allow-once" };
}
if (method === "agent") {
agentParams = params;
return { status: "ok" };
}
if (method === "node.invoke") {
const invoke = params as { command?: string };
if (invoke.command === "system.run.prepare") {
@@ -102,11 +109,24 @@ describe("exec approvals", () => {
host: "node",
ask: "always",
approvalRunningNoticeMs: 0,
sessionKey: "agent:main:main",
});
const result = await tool.execute("call1", { command: "ls -la" });
expect(result.details.status).toBe("approval-pending");
const approvalId = (result.details as { approvalId: string }).approvalId;
const details = result.details as { approvalId: string; approvalSlug: string };
const pendingText = result.content.find((part) => part.type === "text")?.text ?? "";
expect(pendingText).toContain(
`Reply with: /approve ${details.approvalSlug} allow-once|allow-always|deny`,
);
expect(pendingText).toContain(`full ${details.approvalId}`);
expect(pendingText).toContain("Host: node");
expect(pendingText).toContain("Node: node-1");
expect(pendingText).toContain(`CWD: ${process.cwd()}`);
expect(pendingText).toContain("Command:\n```sh\nls -la\n```");
expect(pendingText).toContain("Mode: foreground (interactive approvals available).");
expect(pendingText).toContain("Background mode requires pre-approved policy");
const approvalId = details.approvalId;
await expect
.poll(() => (invokeParams as { params?: { runId?: string } } | undefined)?.params?.runId, {
@@ -114,6 +134,12 @@ describe("exec approvals", () => {
interval: 20,
})
.toBe(approvalId);
expect(
(invokeParams as { params?: { suppressNotifyOnExit?: boolean } } | undefined)?.params,
).toMatchObject({
suppressNotifyOnExit: true,
});
await expect.poll(() => agentParams, { timeout: 2_000, interval: 20 }).toBeTruthy();
});
it("skips approval when node allowlist is satisfied", async () => {
@@ -287,11 +313,181 @@ describe("exec approvals", () => {
const result = await tool.execute("call4", { command: "echo ok", elevated: true });
expect(result.details.status).toBe("approval-pending");
const details = result.details as { approvalId: string; approvalSlug: string };
const pendingText = result.content.find((part) => part.type === "text")?.text ?? "";
expect(pendingText).toContain(
`Reply with: /approve ${details.approvalSlug} allow-once|allow-always|deny`,
);
expect(pendingText).toContain(`full ${details.approvalId}`);
expect(pendingText).toContain("Host: gateway");
expect(pendingText).toContain(`CWD: ${process.cwd()}`);
expect(pendingText).toContain("Command:\n```sh\necho ok\n```");
await approvalSeen;
expect(calls).toContain("exec.approval.request");
expect(calls).toContain("exec.approval.waitDecision");
});
it("starts a direct agent follow-up after approved gateway exec completes", async () => {
const agentCalls: Array<Record<string, unknown>> = [];
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
if (method === "exec.approval.request") {
return { status: "accepted", id: (params as { id?: string })?.id };
}
if (method === "exec.approval.waitDecision") {
return { decision: "allow-once" };
}
if (method === "agent") {
agentCalls.push(params as Record<string, unknown>);
return { status: "ok" };
}
return { ok: true };
});
const tool = createExecTool({
host: "gateway",
ask: "always",
approvalRunningNoticeMs: 0,
sessionKey: "agent:main:main",
elevated: { enabled: true, allowed: true, defaultLevel: "ask" },
});
const result = await tool.execute("call-gw-followup", {
command: "echo ok",
workdir: process.cwd(),
gatewayUrl: undefined,
gatewayToken: undefined,
});
expect(result.details.status).toBe("approval-pending");
await expect.poll(() => agentCalls.length, { timeout: 3_000, interval: 20 }).toBe(1);
expect(agentCalls[0]).toEqual(
expect.objectContaining({
sessionKey: "agent:main:main",
deliver: true,
idempotencyKey: expect.stringContaining("exec-approval-followup:"),
}),
);
expect(typeof agentCalls[0]?.message).toBe("string");
expect(agentCalls[0]?.message).toContain(
"An async command the user already approved has completed.",
);
});
it("requires a separate approval for each elevated command after allow-once", async () => {
const requestCommands: string[] = [];
const requestIds: string[] = [];
const waitIds: string[] = [];
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
if (method === "exec.approval.request") {
const request = params as { id?: string; command?: string };
if (typeof request.command === "string") {
requestCommands.push(request.command);
}
if (typeof request.id === "string") {
requestIds.push(request.id);
}
return { status: "accepted", id: request.id };
}
if (method === "exec.approval.waitDecision") {
const wait = params as { id?: string };
if (typeof wait.id === "string") {
waitIds.push(wait.id);
}
return { decision: "allow-once" };
}
return { ok: true };
});
const tool = createExecTool({
ask: "on-miss",
security: "allowlist",
approvalRunningNoticeMs: 0,
elevated: { enabled: true, allowed: true, defaultLevel: "ask" },
});
const first = await tool.execute("call-seq-1", {
command: "npm view diver --json",
elevated: true,
});
const second = await tool.execute("call-seq-2", {
command: "brew outdated",
elevated: true,
});
expect(first.details.status).toBe("approval-pending");
expect(second.details.status).toBe("approval-pending");
expect(requestCommands).toEqual(["npm view diver --json", "brew outdated"]);
expect(requestIds).toHaveLength(2);
expect(requestIds[0]).not.toBe(requestIds[1]);
expect(waitIds).toEqual(requestIds);
});
it("shows full chained gateway commands in approval-pending message", async () => {
const calls: string[] = [];
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
calls.push(method);
if (method === "exec.approval.request") {
return { status: "accepted", id: (params as { id?: string })?.id };
}
if (method === "exec.approval.waitDecision") {
return { decision: "deny" };
}
return { ok: true };
});
const tool = createExecTool({
host: "gateway",
ask: "on-miss",
security: "allowlist",
approvalRunningNoticeMs: 0,
});
const result = await tool.execute("call-chain-gateway", {
command: "npm view diver --json | jq .name && brew outdated",
});
expect(result.details.status).toBe("approval-pending");
const pendingText = result.content.find((part) => part.type === "text")?.text ?? "";
expect(pendingText).toContain(
"Command:\n```sh\nnpm view diver --json | jq .name && brew outdated\n```",
);
expect(calls).toContain("exec.approval.request");
});
it("shows full chained node commands in approval-pending message", async () => {
const calls: string[] = [];
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
calls.push(method);
if (method === "node.invoke") {
const invoke = params as { command?: string };
if (invoke.command === "system.run.prepare") {
return buildPreparedSystemRunPayload(params);
}
}
return { ok: true };
});
const tool = createExecTool({
host: "node",
ask: "always",
security: "full",
approvalRunningNoticeMs: 0,
});
const result = await tool.execute("call-chain-node", {
command: "npm view diver --json | jq .name && brew outdated",
});
expect(result.details.status).toBe("approval-pending");
const pendingText = result.content.find((part) => part.type === "text")?.text ?? "";
expect(pendingText).toContain(
"Command:\n```sh\nnpm view diver --json | jq .name && brew outdated\n```",
);
expect(calls).toContain("exec.approval.request");
});
it("waits for approval registration before returning approval-pending", async () => {
const calls: string[] = [];
let resolveRegistration: ((value: unknown) => void) | undefined;
@@ -354,6 +550,111 @@ describe("exec approvals", () => {
);
});
it("returns an unavailable approval message instead of a local /approve prompt when discord exec approvals are disabled", async () => {
const configPath = path.join(process.env.HOME ?? "", ".openclaw", "openclaw.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify({
channels: {
discord: {
enabled: true,
execApprovals: { enabled: false },
},
},
}),
);
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
if (method === "exec.approval.request") {
return { status: "accepted", id: "approval-id" };
}
if (method === "exec.approval.waitDecision") {
return { decision: null };
}
return { ok: true };
});
const tool = createExecTool({
host: "gateway",
ask: "always",
approvalRunningNoticeMs: 0,
messageProvider: "discord",
accountId: "default",
currentChannelId: "1234567890",
});
const result = await tool.execute("call-unavailable", {
command: "npm view diver name version description",
});
expect(result.details.status).toBe("approval-unavailable");
const text = result.content.find((part) => part.type === "text")?.text ?? "";
expect(text).toContain("chat exec approvals are not enabled on Discord");
expect(text).toContain("Web UI or terminal UI");
expect(text).not.toContain("/approve");
expect(text).not.toContain("npm view diver name version description");
expect(text).not.toContain("Pending command:");
expect(text).not.toContain("Host:");
expect(text).not.toContain("CWD:");
});
it("tells Telegram users that allowed approvers were DMed when Telegram approvals are disabled but Discord DM approvals are enabled", async () => {
const configPath = path.join(process.env.HOME ?? "", ".openclaw", "openclaw.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify(
{
channels: {
telegram: {
enabled: true,
execApprovals: { enabled: false },
},
discord: {
enabled: true,
execApprovals: { enabled: true, approvers: ["123"], target: "dm" },
},
},
},
null,
2,
),
);
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
if (method === "exec.approval.request") {
return { status: "accepted", id: "approval-id" };
}
if (method === "exec.approval.waitDecision") {
return { decision: null };
}
return { ok: true };
});
const tool = createExecTool({
host: "gateway",
ask: "always",
approvalRunningNoticeMs: 0,
messageProvider: "telegram",
accountId: "default",
currentChannelId: "-1003841603622",
});
const result = await tool.execute("call-tg-unavailable", {
command: "npm view diver name version description",
});
expect(result.details.status).toBe("approval-unavailable");
const text = result.content.find((part) => part.type === "text")?.text ?? "";
expect(text).toContain("Approval required. I sent the allowed approvers DMs.");
expect(text).not.toContain("/approve");
expect(text).not.toContain("npm view diver name version description");
expect(text).not.toContain("Pending command:");
expect(text).not.toContain("Host:");
expect(text).not.toContain("CWD:");
});
it("denies node obfuscated command when approval request times out", async () => {
vi.mocked(detectCommandObfuscation).mockReturnValue({
detected: true,

View File

@@ -1457,6 +1457,7 @@ export async function runEmbeddedPiAgent(
suppressToolErrorWarnings: params.suppressToolErrorWarnings,
inlineToolResultsAllowed: false,
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt,
});
// Timeout aborts can leave the run without any assistant payloads.
@@ -1479,6 +1480,7 @@ export async function runEmbeddedPiAgent(
systemPromptReport: attempt.systemPromptReport,
},
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt,
messagingToolSentTexts: attempt.messagingToolSentTexts,
messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls,
messagingToolSentTargets: attempt.messagingToolSentTargets,
@@ -1526,6 +1528,7 @@ export async function runEmbeddedPiAgent(
: undefined,
},
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt,
messagingToolSentTexts: attempt.messagingToolSentTexts,
messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls,
messagingToolSentTargets: attempt.messagingToolSentTargets,

View File

@@ -1544,6 +1544,7 @@ export async function runEmbeddedAttempt(
getMessagingToolSentTargets,
getSuccessfulCronAdds,
didSendViaMessagingTool,
didSendDeterministicApprovalPrompt,
getLastToolError,
getUsageTotals,
getCompactionCount,
@@ -2058,6 +2059,7 @@ export async function runEmbeddedAttempt(
lastAssistant,
lastToolError: getLastToolError?.(),
didSendViaMessagingTool: didSendViaMessagingTool(),
didSendDeterministicApprovalPrompt: didSendDeterministicApprovalPrompt(),
messagingToolSentTexts: getMessagingToolSentTexts(),
messagingToolSentMediaUrls: getMessagingToolSentMediaUrls(),
messagingToolSentTargets: getMessagingToolSentTargets(),

View File

@@ -1,5 +1,6 @@
import type { ImageContent } from "@mariozechner/pi-ai";
import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-reply/thinking.js";
import type { ReplyPayload } from "../../../auto-reply/types.js";
import type { AgentStreamParams } from "../../../commands/agent/types.js";
import type { OpenClawConfig } from "../../../config/config.js";
import type { enqueueCommand } from "../../../process/command-queue.js";
@@ -104,7 +105,7 @@ export type RunEmbeddedPiAgentParams = {
blockReplyChunking?: BlockReplyChunking;
onReasoningStream?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
onReasoningEnd?: () => void | Promise<void>;
onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
onToolResult?: (payload: ReplyPayload) => void | Promise<void>;
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
lane?: string;
enqueue?: typeof enqueueCommand;

View File

@@ -82,4 +82,13 @@ describe("buildEmbeddedRunPayloads tool-error warnings", () => {
expect(payloads).toHaveLength(0);
});
it("suppresses assistant text when a deterministic exec approval prompt was already delivered", () => {
const payloads = buildPayloads({
assistantTexts: ["Approval is needed. Please run /approve abc allow-once"],
didSendDeterministicApprovalPrompt: true,
});
expect(payloads).toHaveLength(0);
});
});

View File

@@ -102,6 +102,7 @@ export function buildEmbeddedRunPayloads(params: {
suppressToolErrorWarnings?: boolean;
inlineToolResultsAllowed: boolean;
didSendViaMessagingTool?: boolean;
didSendDeterministicApprovalPrompt?: boolean;
}): Array<{
text?: string;
mediaUrl?: string;
@@ -125,14 +126,17 @@ export function buildEmbeddedRunPayloads(params: {
}> = [];
const useMarkdown = params.toolResultFormat === "markdown";
const suppressAssistantArtifacts = params.didSendDeterministicApprovalPrompt === true;
const lastAssistantErrored = params.lastAssistant?.stopReason === "error";
const errorText = params.lastAssistant
? formatAssistantErrorText(params.lastAssistant, {
cfg: params.config,
sessionKey: params.sessionKey,
provider: params.provider,
model: params.model,
})
? suppressAssistantArtifacts
? undefined
: formatAssistantErrorText(params.lastAssistant, {
cfg: params.config,
sessionKey: params.sessionKey,
provider: params.provider,
model: params.model,
})
: undefined;
const rawErrorMessage = lastAssistantErrored
? params.lastAssistant?.errorMessage?.trim() || undefined
@@ -184,8 +188,9 @@ export function buildEmbeddedRunPayloads(params: {
}
}
const reasoningText =
params.lastAssistant && params.reasoningLevel === "on"
const reasoningText = suppressAssistantArtifacts
? ""
: params.lastAssistant && params.reasoningLevel === "on"
? formatReasoningMessage(extractAssistantThinking(params.lastAssistant))
: "";
if (reasoningText) {
@@ -243,13 +248,14 @@ export function buildEmbeddedRunPayloads(params: {
}
return isRawApiErrorPayload(trimmed);
};
const answerTexts = (
params.assistantTexts.length
? params.assistantTexts
: fallbackAnswerText
? [fallbackAnswerText]
: []
).filter((text) => !shouldSuppressRawErrorText(text));
const answerTexts = suppressAssistantArtifacts
? []
: (params.assistantTexts.length
? params.assistantTexts
: fallbackAnswerText
? [fallbackAnswerText]
: []
).filter((text) => !shouldSuppressRawErrorText(text));
let hasUserFacingAssistantReply = false;
for (const text of answerTexts) {

View File

@@ -54,6 +54,7 @@ export type EmbeddedRunAttemptResult = {
actionFingerprint?: string;
};
didSendViaMessagingTool: boolean;
didSendDeterministicApprovalPrompt?: boolean;
messagingToolSentTexts: string[];
messagingToolSentMediaUrls: string[];
messagingToolSentTargets: MessagingToolSend[];

View File

@@ -85,6 +85,9 @@ export function handleMessageUpdate(
}
ctx.noteLastAssistant(msg);
if (ctx.state.deterministicApprovalPromptSent) {
return;
}
const assistantEvent = evt.assistantMessageEvent;
const assistantRecord =
@@ -261,6 +264,9 @@ export function handleMessageEnd(
const assistantMessage = msg;
ctx.noteLastAssistant(assistantMessage);
ctx.recordAssistantUsage((assistantMessage as { usage?: unknown }).usage);
if (ctx.state.deterministicApprovalPromptSent) {
return;
}
promoteThinkingTagsToBlocks(assistantMessage);
const rawText = extractAssistantText(assistantMessage);

View File

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

View File

@@ -45,6 +45,7 @@ function createTestContext(): {
messagingToolSentMediaUrls: [],
messagingToolSentTargets: [],
successfulCronAdds: 0,
deterministicApprovalPromptSent: false,
},
shouldEmitToolResult: () => false,
shouldEmitToolOutput: () => false,
@@ -175,6 +176,161 @@ describe("handleToolExecutionEnd cron.add commitment tracking", () => {
});
});
describe("handleToolExecutionEnd exec approval prompts", () => {
it("emits a deterministic approval payload and marks assistant output suppressed", async () => {
const { ctx } = createTestContext();
const onToolResult = vi.fn();
ctx.params.onToolResult = onToolResult;
await handleToolExecutionEnd(
ctx as never,
{
type: "tool_execution_end",
toolName: "exec",
toolCallId: "tool-exec-approval",
isError: false,
result: {
details: {
status: "approval-pending",
approvalId: "12345678-1234-1234-1234-123456789012",
approvalSlug: "12345678",
expiresAtMs: 1_800_000_000_000,
host: "gateway",
command: "npm view diver name version description",
cwd: "/tmp/work",
warningText: "Warning: heredoc execution requires explicit approval in allowlist mode.",
},
},
} as never,
);
expect(onToolResult).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.stringContaining("```txt\n/approve 12345678 allow-once\n```"),
channelData: {
execApproval: {
approvalId: "12345678-1234-1234-1234-123456789012",
approvalSlug: "12345678",
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
},
}),
);
expect(ctx.state.deterministicApprovalPromptSent).toBe(true);
});
it("emits a deterministic unavailable payload when the initiating surface cannot approve", async () => {
const { ctx } = createTestContext();
const onToolResult = vi.fn();
ctx.params.onToolResult = onToolResult;
await handleToolExecutionEnd(
ctx as never,
{
type: "tool_execution_end",
toolName: "exec",
toolCallId: "tool-exec-unavailable",
isError: false,
result: {
details: {
status: "approval-unavailable",
reason: "initiating-platform-disabled",
channelLabel: "Discord",
},
},
} as never,
);
expect(onToolResult).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.stringContaining("chat exec approvals are not enabled on Discord"),
}),
);
expect(onToolResult).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.not.stringContaining("/approve"),
}),
);
expect(onToolResult).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.not.stringContaining("Pending command:"),
}),
);
expect(onToolResult).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.not.stringContaining("Host:"),
}),
);
expect(onToolResult).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.not.stringContaining("CWD:"),
}),
);
expect(ctx.state.deterministicApprovalPromptSent).toBe(true);
});
it("emits the shared approver-DM notice when another approval client received the request", async () => {
const { ctx } = createTestContext();
const onToolResult = vi.fn();
ctx.params.onToolResult = onToolResult;
await handleToolExecutionEnd(
ctx as never,
{
type: "tool_execution_end",
toolName: "exec",
toolCallId: "tool-exec-unavailable-dm-redirect",
isError: false,
result: {
details: {
status: "approval-unavailable",
reason: "initiating-platform-disabled",
channelLabel: "Telegram",
sentApproverDms: true,
},
},
} as never,
);
expect(onToolResult).toHaveBeenCalledWith(
expect.objectContaining({
text: "Approval required. I sent the allowed approvers DMs.",
}),
);
expect(ctx.state.deterministicApprovalPromptSent).toBe(true);
});
it("does not suppress assistant output when deterministic prompt delivery rejects", async () => {
const { ctx } = createTestContext();
ctx.params.onToolResult = vi.fn(async () => {
throw new Error("delivery failed");
});
await handleToolExecutionEnd(
ctx as never,
{
type: "tool_execution_end",
toolName: "exec",
toolCallId: "tool-exec-approval-reject",
isError: false,
result: {
details: {
status: "approval-pending",
approvalId: "12345678-1234-1234-1234-123456789012",
approvalSlug: "12345678",
expiresAtMs: 1_800_000_000_000,
host: "gateway",
command: "npm view diver name version description",
cwd: "/tmp/work",
},
},
} as never,
);
expect(ctx.state.deterministicApprovalPromptSent).toBe(false);
});
});
describe("messaging tool media URL tracking", () => {
it("tracks media arg from messaging tool as pending", async () => {
const { ctx } = createTestContext();

View File

@@ -1,5 +1,9 @@
import type { AgentEvent } from "@mariozechner/pi-agent-core";
import { emitAgentEvent } from "../infra/agent-events.js";
import {
buildExecApprovalPendingReplyPayload,
buildExecApprovalUnavailableReplyPayload,
} from "../infra/exec-approval-reply.js";
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import type { PluginHookAfterToolCallEvent } from "../plugins/types.js";
import { normalizeTextForComparison } from "./pi-embedded-helpers.js";
@@ -139,7 +143,81 @@ function collectMessagingMediaUrlsFromToolResult(result: unknown): string[] {
return urls;
}
function emitToolResultOutput(params: {
function readExecApprovalPendingDetails(result: unknown): {
approvalId: string;
approvalSlug: string;
expiresAtMs?: number;
host: "gateway" | "node";
command: string;
cwd?: string;
nodeId?: string;
warningText?: string;
} | null {
if (!result || typeof result !== "object") {
return null;
}
const outer = result as Record<string, unknown>;
const details =
outer.details && typeof outer.details === "object" && !Array.isArray(outer.details)
? (outer.details as Record<string, unknown>)
: outer;
if (details.status !== "approval-pending") {
return null;
}
const approvalId = typeof details.approvalId === "string" ? details.approvalId.trim() : "";
const approvalSlug = typeof details.approvalSlug === "string" ? details.approvalSlug.trim() : "";
const command = typeof details.command === "string" ? details.command : "";
const host = details.host === "node" ? "node" : details.host === "gateway" ? "gateway" : null;
if (!approvalId || !approvalSlug || !command || !host) {
return null;
}
return {
approvalId,
approvalSlug,
expiresAtMs: typeof details.expiresAtMs === "number" ? details.expiresAtMs : undefined,
host,
command,
cwd: typeof details.cwd === "string" ? details.cwd : undefined,
nodeId: typeof details.nodeId === "string" ? details.nodeId : undefined,
warningText: typeof details.warningText === "string" ? details.warningText : undefined,
};
}
function readExecApprovalUnavailableDetails(result: unknown): {
reason: "initiating-platform-disabled" | "initiating-platform-unsupported" | "no-approval-route";
warningText?: string;
channelLabel?: string;
sentApproverDms?: boolean;
} | null {
if (!result || typeof result !== "object") {
return null;
}
const outer = result as Record<string, unknown>;
const details =
outer.details && typeof outer.details === "object" && !Array.isArray(outer.details)
? (outer.details as Record<string, unknown>)
: outer;
if (details.status !== "approval-unavailable") {
return null;
}
const reason =
details.reason === "initiating-platform-disabled" ||
details.reason === "initiating-platform-unsupported" ||
details.reason === "no-approval-route"
? details.reason
: null;
if (!reason) {
return null;
}
return {
reason,
warningText: typeof details.warningText === "string" ? details.warningText : undefined,
channelLabel: typeof details.channelLabel === "string" ? details.channelLabel : undefined,
sentApproverDms: details.sentApproverDms === true,
};
}
async function emitToolResultOutput(params: {
ctx: ToolHandlerContext;
toolName: string;
meta?: string;
@@ -152,6 +230,46 @@ function emitToolResultOutput(params: {
return;
}
const approvalPending = readExecApprovalPendingDetails(result);
if (!isToolError && approvalPending) {
try {
await ctx.params.onToolResult(
buildExecApprovalPendingReplyPayload({
approvalId: approvalPending.approvalId,
approvalSlug: approvalPending.approvalSlug,
command: approvalPending.command,
cwd: approvalPending.cwd,
host: approvalPending.host,
nodeId: approvalPending.nodeId,
expiresAtMs: approvalPending.expiresAtMs,
warningText: approvalPending.warningText,
}),
);
ctx.state.deterministicApprovalPromptSent = true;
} catch {
// ignore delivery failures
}
return;
}
const approvalUnavailable = readExecApprovalUnavailableDetails(result);
if (!isToolError && approvalUnavailable) {
try {
await ctx.params.onToolResult?.(
buildExecApprovalUnavailableReplyPayload({
reason: approvalUnavailable.reason,
warningText: approvalUnavailable.warningText,
channelLabel: approvalUnavailable.channelLabel,
sentApproverDms: approvalUnavailable.sentApproverDms,
}),
);
ctx.state.deterministicApprovalPromptSent = true;
} catch {
// ignore delivery failures
}
return;
}
if (ctx.shouldEmitToolOutput()) {
const outputText = extractToolResultText(sanitizedResult);
if (outputText) {
@@ -427,7 +545,7 @@ export async function handleToolExecutionEnd(
`embedded run tool end: runId=${ctx.params.runId} tool=${toolName} toolCallId=${toolCallId}`,
);
emitToolResultOutput({ ctx, toolName, meta, isToolError, result, sanitizedResult });
await emitToolResultOutput({ ctx, toolName, meta, isToolError, result, sanitizedResult });
// Run after_tool_call plugin hook (fire-and-forget)
const hookRunnerAfter = ctx.hookRunner ?? getGlobalHookRunner();

View File

@@ -76,6 +76,7 @@ export type EmbeddedPiSubscribeState = {
pendingMessagingTargets: Map<string, MessagingToolSend>;
successfulCronAdds: number;
pendingMessagingMediaUrls: Map<string, string[]>;
deterministicApprovalPromptSent: boolean;
lastAssistant?: AgentMessage;
};
@@ -155,6 +156,7 @@ export type ToolHandlerState = Pick<
| "messagingToolSentMediaUrls"
| "messagingToolSentTargets"
| "successfulCronAdds"
| "deterministicApprovalPromptSent"
>;
export type ToolHandlerContext = {

View File

@@ -78,6 +78,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
pendingMessagingTargets: new Map(),
successfulCronAdds: 0,
pendingMessagingMediaUrls: new Map(),
deterministicApprovalPromptSent: false,
};
const usageTotals = {
input: 0,
@@ -598,6 +599,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
pendingMessagingTargets.clear();
state.successfulCronAdds = 0;
state.pendingMessagingMediaUrls.clear();
state.deterministicApprovalPromptSent = false;
resetAssistantMessageState(0);
};
@@ -688,6 +690,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
// Used to suppress agent's confirmation text (e.g., "Respondi no Telegram!")
// which is generated AFTER the tool sends the actual answer.
didSendViaMessagingTool: () => messagingToolSentTexts.length > 0,
didSendDeterministicApprovalPrompt: () => state.deterministicApprovalPromptSent,
getLastToolError: () => (state.lastToolError ? { ...state.lastToolError } : undefined),
getUsageTotals,
getCompactionCount: () => compactionCount,

View File

@@ -1,5 +1,6 @@
import type { AgentSession } from "@mariozechner/pi-coding-agent";
import type { ReasoningLevel, VerboseLevel } from "../auto-reply/thinking.js";
import type { ReplyPayload } from "../auto-reply/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { HookRunner } from "../plugins/hooks.js";
import type { BlockReplyChunking } from "./pi-embedded-block-chunker.js";
@@ -16,7 +17,7 @@ export type SubscribeEmbeddedPiSessionParams = {
toolResultFormat?: ToolResultFormat;
shouldEmitToolResult?: () => boolean;
shouldEmitToolOutput?: () => boolean;
onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
onToolResult?: (payload: ReplyPayload) => void | Promise<void>;
onReasoningStream?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
/** Called when a thinking/reasoning block ends (</think> tag processed). */
onReasoningEnd?: () => void | Promise<void>;

View File

@@ -10,6 +10,7 @@ export function createBaseToolHandlerState() {
messagingToolSentTextsNormalized: [] as string[],
messagingToolSentMediaUrls: [] as string[],
messagingToolSentTargets: [] as unknown[],
deterministicApprovalPromptSent: false,
blockBuffer: "",
};
}

View File

@@ -464,6 +464,9 @@ export function buildAgentSystemPrompt(params: {
"Keep narration brief and value-dense; avoid repeating obvious steps.",
"Use plain human language for narration unless in a technical context.",
"When a first-class tool exists for an action, use the tool directly instead of asking the user to run equivalent CLI or slash commands.",
"When exec returns approval-pending, include the concrete /approve command from tool output (with allow-once|allow-always|deny) and do not ask for a different or rotated code.",
"Treat allow-once as single-command only: if another elevated command needs approval, request a fresh /approve and do not claim prior approval covered it.",
"When approvals are required, preserve and show the full command/script exactly as provided (including chained operators like &&, ||, |, ;, or multiline shells) so the user can approve what will actually run.",
"",
...safetySection,
"## OpenClaw CLI Quick Reference",

View File

@@ -445,8 +445,8 @@ export async function runAgentTurnWithFallback(params: {
}
await params.typingSignals.signalTextDelta(text);
await onToolResult({
...payload,
text,
mediaUrls: payload.mediaUrls,
});
})
.catch((err) => {

View File

@@ -12,6 +12,7 @@ vi.mock("../../agents/agent-scope.js", () => ({
}));
const {
buildThreadingToolContext,
buildEmbeddedRunBaseParams,
buildEmbeddedRunContexts,
resolveModelFallbackOptions,
@@ -173,4 +174,44 @@ describe("agent-runner-utils", () => {
expect(resolved.embeddedContext.messageProvider).toBe("telegram");
expect(resolved.embeddedContext.messageTo).toBe("268300329");
});
it("uses OriginatingTo for threading tool context on telegram native commands", () => {
const context = buildThreadingToolContext({
sessionCtx: {
Provider: "telegram",
To: "slash:8460800771",
OriginatingChannel: "telegram",
OriginatingTo: "telegram:-1003841603622",
MessageThreadId: 928,
MessageSid: "2284",
},
config: { channels: { telegram: { allowFrom: ["*"] } } },
hasRepliedRef: undefined,
});
expect(context).toMatchObject({
currentChannelId: "telegram:-1003841603622",
currentThreadTs: "928",
currentMessageId: "2284",
});
});
it("uses OriginatingTo for threading tool context on discord native commands", () => {
const context = buildThreadingToolContext({
sessionCtx: {
Provider: "discord",
To: "slash:1177378744822943744",
OriginatingChannel: "discord",
OriginatingTo: "channel:123456789012345678",
MessageSid: "msg-9",
},
config: {},
hasRepliedRef: undefined,
});
expect(context).toMatchObject({
currentChannelId: "channel:123456789012345678",
currentMessageId: "msg-9",
});
});
});

View File

@@ -23,12 +23,20 @@ export function buildThreadingToolContext(params: {
}): ChannelThreadingToolContext {
const { sessionCtx, config, hasRepliedRef } = params;
const currentMessageId = sessionCtx.MessageSidFull ?? sessionCtx.MessageSid;
const originProvider = resolveOriginMessageProvider({
originatingChannel: sessionCtx.OriginatingChannel,
provider: sessionCtx.Provider,
});
const originTo = resolveOriginMessageTo({
originatingTo: sessionCtx.OriginatingTo,
to: sessionCtx.To,
});
if (!config) {
return {
currentMessageId,
};
}
const rawProvider = sessionCtx.Provider?.trim().toLowerCase();
const rawProvider = originProvider?.trim().toLowerCase();
if (!rawProvider) {
return {
currentMessageId,
@@ -39,7 +47,7 @@ export function buildThreadingToolContext(params: {
const dock = provider ? getChannelDock(provider) : undefined;
if (!dock?.threading?.buildToolContext) {
return {
currentChannelId: sessionCtx.To?.trim() || undefined,
currentChannelId: originTo?.trim() || undefined,
currentChannelProvider: provider ?? (rawProvider as ChannelId),
currentMessageId,
hasRepliedRef,
@@ -50,9 +58,9 @@ export function buildThreadingToolContext(params: {
cfg: config,
accountId: sessionCtx.AccountId,
context: {
Channel: sessionCtx.Provider,
Channel: originProvider,
From: sessionCtx.From,
To: sessionCtx.To,
To: originTo,
ChatType: sessionCtx.ChatType,
CurrentMessageId: currentMessageId,
ReplyToId: sessionCtx.ReplyToId,

View File

@@ -21,7 +21,7 @@ type AgentRunParams = {
onAssistantMessageStart?: () => Promise<void> | void;
onReasoningStream?: (payload: { text?: string }) => Promise<void> | void;
onBlockReply?: (payload: { text?: string; mediaUrls?: string[] }) => Promise<void> | void;
onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => Promise<void> | void;
onToolResult?: (payload: ReplyPayload) => Promise<void> | void;
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
};
@@ -594,6 +594,40 @@ describe("runReplyAgent typing (heartbeat)", () => {
}
});
it("preserves channelData on forwarded tool results", async () => {
const onToolResult = vi.fn();
state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => {
await params.onToolResult?.({
text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```",
channelData: {
execApproval: {
approvalId: "117ba06d-1111-2222-3333-444444444444",
approvalSlug: "117ba06d",
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
},
});
return { payloads: [{ text: "final" }], meta: {} };
});
const { run } = createMinimalRun({
typingMode: "message",
opts: { onToolResult },
});
await run();
expect(onToolResult).toHaveBeenCalledWith({
text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```",
channelData: {
execApproval: {
approvalId: "117ba06d-1111-2222-3333-444444444444",
approvalSlug: "117ba06d",
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
},
});
});
it("retries transient HTTP failures once with timer-driven backoff", async () => {
vi.useFakeTimers();
let calls = 0;
@@ -1952,3 +1986,4 @@ describe("runReplyAgent memory flush", () => {
});
});
});
import type { ReplyPayload } from "../types.js";

View File

@@ -1,10 +1,15 @@
import { callGateway } from "../../gateway/call.js";
import { logVerbose } from "../../globals.js";
import {
isTelegramExecApprovalApprover,
isTelegramExecApprovalClientEnabled,
} from "../../telegram/exec-approvals.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
import { requireGatewayClientScopeForInternalChannel } from "./command-gates.js";
import type { CommandHandler } from "./commands-types.js";
const COMMAND = "/approve";
const COMMAND_REGEX = /^\/approve(?:\s|$)/i;
const FOREIGN_COMMAND_MENTION_REGEX = /^\/approve@([^\s]+)(?:\s|$)/i;
const DECISION_ALIASES: Record<string, "allow-once" | "allow-always" | "deny"> = {
allow: "allow-once",
@@ -25,10 +30,14 @@ type ParsedApproveCommand =
function parseApproveCommand(raw: string): ParsedApproveCommand | null {
const trimmed = raw.trim();
if (!trimmed.toLowerCase().startsWith(COMMAND)) {
if (FOREIGN_COMMAND_MENTION_REGEX.test(trimmed)) {
return { ok: false, error: "❌ This /approve command targets a different Telegram bot." };
}
const commandMatch = trimmed.match(COMMAND_REGEX);
if (!commandMatch) {
return null;
}
const rest = trimmed.slice(COMMAND.length).trim();
const rest = trimmed.slice(commandMatch[0].length).trim();
if (!rest) {
return { ok: false, error: "Usage: /approve <id> allow-once|allow-always|deny" };
}
@@ -83,6 +92,29 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm
return { shouldContinue: false, reply: { text: parsed.error } };
}
if (params.command.channel === "telegram") {
if (
!isTelegramExecApprovalClientEnabled({ cfg: params.cfg, accountId: params.ctx.AccountId })
) {
return {
shouldContinue: false,
reply: { text: "❌ Telegram exec approvals are not enabled for this bot account." },
};
}
if (
!isTelegramExecApprovalApprover({
cfg: params.cfg,
accountId: params.ctx.AccountId,
senderId: params.command.senderId,
})
) {
return {
shouldContinue: false,
reply: { text: "❌ You are not authorized to approve exec requests on Telegram." },
};
}
}
const missingScope = requireGatewayClientScopeForInternalChannel(params, {
label: "/approve",
allowedScopes: ["operator.approvals", "operator.admin"],

View File

@@ -26,6 +26,7 @@ export function buildCommandContext(params: {
const rawBodyNormalized = triggerBodyNormalized;
const commandBodyNormalized = normalizeCommandBody(
isGroup ? stripMentions(rawBodyNormalized, ctx, cfg, agentId) : rawBodyNormalized,
{ botUsername: ctx.BotUsername },
);
return {

View File

@@ -105,27 +105,6 @@ vi.mock("../../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
type ResetAcpSessionInPlaceResult = { ok: true } | { ok: false; skipped?: boolean; error?: string };
const resetAcpSessionInPlaceMock = vi.hoisted(() =>
vi.fn(
async (_params: unknown): Promise<ResetAcpSessionInPlaceResult> => ({
ok: false,
skipped: true,
}),
),
);
vi.mock("../../acp/persistent-bindings.js", async () => {
const actual = await vi.importActual<typeof import("../../acp/persistent-bindings.js")>(
"../../acp/persistent-bindings.js",
);
return {
...actual,
resetAcpSessionInPlace: (params: unknown) => resetAcpSessionInPlaceMock(params),
};
});
import { buildConfiguredAcpSessionKey } from "../../acp/persistent-bindings.js";
import type { HandleCommandsParams } from "./commands-types.js";
import { buildCommandContext, handleCommands } from "./commands.js";
@@ -158,11 +137,6 @@ function buildParams(commandBody: string, cfg: OpenClawConfig, ctxOverrides?: Pa
return buildCommandTestParams(commandBody, cfg, ctxOverrides, { workspaceDir: testWorkspaceDir });
}
beforeEach(() => {
resetAcpSessionInPlaceMock.mockReset();
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: false, skipped: true } as const);
});
describe("handleCommands gating", () => {
it("blocks gated commands when disabled or not elevated-allowlisted", async () => {
const cases = typedCases<{
@@ -316,6 +290,122 @@ describe("/approve command", () => {
);
});
it("accepts Telegram command mentions for /approve", async () => {
const cfg = {
commands: { text: true },
channels: {
telegram: {
allowFrom: ["*"],
execApprovals: { enabled: true, approvers: ["123"], target: "dm" },
},
},
} as OpenClawConfig;
const params = buildParams("/approve@bot abc12345 allow-once", cfg, {
BotUsername: "bot",
Provider: "telegram",
Surface: "telegram",
SenderId: "123",
});
callGatewayMock.mockResolvedValue({ ok: true });
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Exec approval allow-once submitted");
expect(callGatewayMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "exec.approval.resolve",
params: { id: "abc12345", decision: "allow-once" },
}),
);
});
it("rejects Telegram /approve mentions targeting a different bot", async () => {
const cfg = {
commands: { text: true },
channels: {
telegram: {
allowFrom: ["*"],
execApprovals: { enabled: true, approvers: ["123"], target: "dm" },
},
},
} as OpenClawConfig;
const params = buildParams("/approve@otherbot abc12345 allow-once", cfg, {
BotUsername: "bot",
Provider: "telegram",
Surface: "telegram",
SenderId: "123",
});
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("targets a different Telegram bot");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("surfaces unknown or expired approval id errors", async () => {
const cfg = {
commands: { text: true },
channels: {
telegram: {
allowFrom: ["*"],
execApprovals: { enabled: true, approvers: ["123"], target: "dm" },
},
},
} as OpenClawConfig;
const params = buildParams("/approve abc12345 allow-once", cfg, {
Provider: "telegram",
Surface: "telegram",
SenderId: "123",
});
callGatewayMock.mockRejectedValue(new Error("unknown or expired approval id"));
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("unknown or expired approval id");
});
it("rejects Telegram /approve when telegram exec approvals are disabled", async () => {
const cfg = {
commands: { text: true },
channels: { telegram: { allowFrom: ["*"] } },
} as OpenClawConfig;
const params = buildParams("/approve abc12345 allow-once", cfg, {
Provider: "telegram",
Surface: "telegram",
SenderId: "123",
});
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Telegram exec approvals are not enabled");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("rejects Telegram /approve from non-approvers", async () => {
const cfg = {
commands: { text: true },
channels: {
telegram: {
allowFrom: ["*"],
execApprovals: { enabled: true, approvers: ["999"], target: "dm" },
},
},
} as OpenClawConfig;
const params = buildParams("/approve abc12345 allow-once", cfg, {
Provider: "telegram",
Surface: "telegram",
SenderId: "123",
});
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("not authorized to approve");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("rejects gateway clients without approvals scope", async () => {
const cfg = {
commands: { text: true },
@@ -1147,226 +1237,6 @@ describe("handleCommands hooks", () => {
});
});
describe("handleCommands ACP-bound /new and /reset", () => {
const discordChannelId = "1478836151241412759";
const buildDiscordBoundConfig = (): OpenClawConfig =>
({
commands: { text: true },
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "default",
peer: {
kind: "channel",
id: discordChannelId,
},
},
acp: {
mode: "persistent",
},
},
],
channels: {
discord: {
allowFrom: ["*"],
guilds: { "1459246755253325866": { channels: { [discordChannelId]: {} } } },
},
},
}) as OpenClawConfig;
const buildDiscordBoundParams = (body: string) => {
const params = buildParams(body, buildDiscordBoundConfig(), {
Provider: "discord",
Surface: "discord",
OriginatingChannel: "discord",
AccountId: "default",
SenderId: "12345",
From: "discord:12345",
To: discordChannelId,
OriginatingTo: discordChannelId,
SessionKey: "agent:main:acp:binding:discord:default:feedface",
});
params.sessionKey = "agent:main:acp:binding:discord:default:feedface";
return params;
};
it("handles /new as ACP in-place reset for bound conversations", async () => {
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const);
const result = await handleCommands(buildDiscordBoundParams("/new"));
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("ACP session reset in place");
expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1);
expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({
reason: "new",
});
});
it("continues with trailing prompt text after successful ACP-bound /new", async () => {
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const);
const params = buildDiscordBoundParams("/new continue with deployment");
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply).toBeUndefined();
const mutableCtx = params.ctx as Record<string, unknown>;
expect(mutableCtx.BodyStripped).toBe("continue with deployment");
expect(mutableCtx.CommandBody).toBe("continue with deployment");
expect(mutableCtx.AcpDispatchTailAfterReset).toBe(true);
expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1);
});
it("handles /reset failures without falling back to normal session reset flow", async () => {
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: false, error: "backend unavailable" });
const result = await handleCommands(buildDiscordBoundParams("/reset"));
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("ACP session reset failed");
expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1);
expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({
reason: "reset",
});
});
it("does not emit reset hooks when ACP reset fails", async () => {
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: false, error: "backend unavailable" });
const spy = vi.spyOn(internalHooks, "triggerInternalHook").mockResolvedValue();
const result = await handleCommands(buildDiscordBoundParams("/reset"));
expect(result.shouldContinue).toBe(false);
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});
it("keeps existing /new behavior for non-ACP sessions", async () => {
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as OpenClawConfig;
const result = await handleCommands(buildParams("/new", cfg));
expect(result.shouldContinue).toBe(true);
expect(resetAcpSessionInPlaceMock).not.toHaveBeenCalled();
});
it("still targets configured ACP binding when runtime routing falls back to a non-ACP session", async () => {
const fallbackSessionKey = `agent:main:discord:channel:${discordChannelId}`;
const configuredAcpSessionKey = buildConfiguredAcpSessionKey({
channel: "discord",
accountId: "default",
conversationId: discordChannelId,
agentId: "codex",
mode: "persistent",
});
const params = buildDiscordBoundParams("/new");
params.sessionKey = fallbackSessionKey;
params.ctx.SessionKey = fallbackSessionKey;
params.ctx.CommandTargetSessionKey = fallbackSessionKey;
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("ACP session reset unavailable");
expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1);
expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({
sessionKey: configuredAcpSessionKey,
reason: "new",
});
});
it("emits reset hooks for the ACP session key when routing falls back to non-ACP session", async () => {
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const);
const hookSpy = vi.spyOn(internalHooks, "triggerInternalHook").mockResolvedValue();
const fallbackSessionKey = `agent:main:discord:channel:${discordChannelId}`;
const configuredAcpSessionKey = buildConfiguredAcpSessionKey({
channel: "discord",
accountId: "default",
conversationId: discordChannelId,
agentId: "codex",
mode: "persistent",
});
const fallbackEntry = {
sessionId: "fallback-session-id",
sessionFile: "/tmp/fallback-session.jsonl",
} as SessionEntry;
const configuredEntry = {
sessionId: "configured-acp-session-id",
sessionFile: "/tmp/configured-acp-session.jsonl",
} as SessionEntry;
const params = buildDiscordBoundParams("/new");
params.sessionKey = fallbackSessionKey;
params.ctx.SessionKey = fallbackSessionKey;
params.ctx.CommandTargetSessionKey = fallbackSessionKey;
params.sessionEntry = fallbackEntry;
params.previousSessionEntry = fallbackEntry;
params.sessionStore = {
[fallbackSessionKey]: fallbackEntry,
[configuredAcpSessionKey]: configuredEntry,
};
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("ACP session reset in place");
expect(hookSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: "command",
action: "new",
sessionKey: configuredAcpSessionKey,
context: expect.objectContaining({
sessionEntry: configuredEntry,
previousSessionEntry: configuredEntry,
}),
}),
);
hookSpy.mockRestore();
});
it("uses active ACP command target when conversation binding context is missing", async () => {
resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const);
const activeAcpTarget = "agent:codex:acp:binding:discord:default:feedface";
const params = buildParams(
"/new",
{
commands: { text: true },
channels: {
discord: {
allowFrom: ["*"],
},
},
} as OpenClawConfig,
{
Provider: "discord",
Surface: "discord",
OriginatingChannel: "discord",
AccountId: "default",
SenderId: "12345",
From: "discord:12345",
},
);
params.sessionKey = "discord:slash:12345";
params.ctx.SessionKey = "discord:slash:12345";
params.ctx.CommandSource = "native";
params.ctx.CommandTargetSessionKey = activeAcpTarget;
params.ctx.To = "user:12345";
params.ctx.OriginatingTo = "user:12345";
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("ACP session reset in place");
expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1);
expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({
sessionKey: activeAcpTarget,
reason: "new",
});
});
});
describe("handleCommands context", () => {
it("returns expected details for /context commands", async () => {
const cfg = {

View File

@@ -543,6 +543,51 @@ describe("dispatchReplyFromConfig", () => {
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
});
it("delivers deterministic exec approval tool payloads in groups", async () => {
setNoAbort();
const cfg = emptyConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "telegram",
ChatType: "group",
});
const replyResolver = async (
_ctx: MsgContext,
opts?: GetReplyOptions,
_cfg?: OpenClawConfig,
) => {
await opts?.onToolResult?.({
text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```",
channelData: {
execApproval: {
approvalId: "117ba06d-1111-2222-3333-444444444444",
approvalSlug: "117ba06d",
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
},
});
return { text: "NO_REPLY" } satisfies ReplyPayload;
};
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(1);
expect(firstToolResultPayload(dispatcher)).toEqual(
expect.objectContaining({
text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```",
channelData: {
execApproval: {
approvalId: "117ba06d-1111-2222-3333-444444444444",
approvalSlug: "117ba06d",
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
},
}),
);
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "NO_REPLY" });
});
it("sends tool results via dispatcher in DM sessions", async () => {
setNoAbort();
const cfg = emptyConfig;
@@ -601,6 +646,50 @@ describe("dispatchReplyFromConfig", () => {
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
});
it("delivers deterministic exec approval tool payloads for native commands", async () => {
setNoAbort();
const cfg = emptyConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "telegram",
CommandSource: "native",
});
const replyResolver = async (
_ctx: MsgContext,
opts?: GetReplyOptions,
_cfg?: OpenClawConfig,
) => {
await opts?.onToolResult?.({
text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```",
channelData: {
execApproval: {
approvalId: "117ba06d-1111-2222-3333-444444444444",
approvalSlug: "117ba06d",
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
},
});
return { text: "NO_REPLY" } satisfies ReplyPayload;
};
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(1);
expect(firstToolResultPayload(dispatcher)).toEqual(
expect.objectContaining({
channelData: {
execApproval: {
approvalId: "117ba06d-1111-2222-3333-444444444444",
approvalSlug: "117ba06d",
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
},
}),
);
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "NO_REPLY" });
});
it("fast-aborts without calling the reply resolver", async () => {
mocks.tryFastAbortFromMessage.mockResolvedValue({
handled: true,
@@ -1539,6 +1628,47 @@ describe("dispatchReplyFromConfig", () => {
expect(replyResolver).toHaveBeenCalledTimes(1);
});
it("suppresses local discord exec approval tool prompts when discord approvals are enabled", async () => {
setNoAbort();
const cfg = {
channels: {
discord: {
enabled: true,
execApprovals: {
enabled: true,
approvers: ["123"],
},
},
},
} as OpenClawConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "discord",
Surface: "discord",
AccountId: "default",
});
const replyResolver = vi.fn(async (_ctx: MsgContext, options?: GetReplyOptions) => {
await options?.onToolResult?.({
text: "Approval required.",
channelData: {
execApproval: {
approvalId: "12345678-1234-1234-1234-123456789012",
approvalSlug: "12345678",
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
},
});
return { text: "done" } as ReplyPayload;
});
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith(
expect.objectContaining({ text: "done" }),
);
});
it("deduplicates same-agent inbound replies across main and direct session keys", async () => {
setNoAbort();
const cfg = emptyConfig;

View File

@@ -6,6 +6,7 @@ import {
resolveStorePath,
type SessionEntry,
} from "../../config/sessions.js";
import { shouldSuppressLocalDiscordExecApprovalPrompt } from "../../discord/exec-approvals.js";
import { logVerbose } from "../../globals.js";
import { fireAndForgetHook } from "../../hooks/fire-and-forget.js";
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
@@ -365,9 +366,28 @@ export async function dispatchReplyFromConfig(params: {
let blockCount = 0;
const resolveToolDeliveryPayload = (payload: ReplyPayload): ReplyPayload | null => {
if (
normalizeMessageChannel(ctx.Surface ?? ctx.Provider) === "discord" &&
shouldSuppressLocalDiscordExecApprovalPrompt({
cfg,
accountId: ctx.AccountId,
payload,
})
) {
return null;
}
if (shouldSendToolSummaries) {
return payload;
}
const execApproval =
payload.channelData &&
typeof payload.channelData === "object" &&
!Array.isArray(payload.channelData)
? payload.channelData.execApproval
: undefined;
if (execApproval && typeof execApproval === "object" && !Array.isArray(execApproval)) {
return payload;
}
// Group/native flows intentionally suppress tool summary text, but media-only
// tool results (for example TTS audio) must still be delivered.
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;

View File

@@ -132,6 +132,8 @@ export type MsgContext = {
Provider?: string;
/** Provider surface label (e.g. discord, slack). Prefer this over `Provider` when available. */
Surface?: string;
/** Platform bot username when command mentions should be normalized. */
BotUsername?: string;
WasMentioned?: boolean;
CommandAuthorized?: boolean;
CommandSource?: "text" | "native";

View File

@@ -115,7 +115,6 @@ export const telegramOutbound: ChannelOutboundAdapter = {
quoteText,
mediaLocalRoots,
};
if (mediaUrls.length === 0) {
const result = await send(to, text, {
...payloadOpts,

View File

@@ -522,6 +522,12 @@ const CHANNELS_AGENTS_TARGET_KEYS = [
"channels.telegram",
"channels.telegram.botToken",
"channels.telegram.capabilities.inlineButtons",
"channels.telegram.execApprovals",
"channels.telegram.execApprovals.enabled",
"channels.telegram.execApprovals.approvers",
"channels.telegram.execApprovals.agentFilter",
"channels.telegram.execApprovals.sessionFilter",
"channels.telegram.execApprovals.target",
"channels.whatsapp",
] as const;

View File

@@ -1383,6 +1383,18 @@ export const FIELD_HELP: Record<string, string> = {
"Telegram bot token used to authenticate Bot API requests for this account/provider config. Use secret/env substitution and rotate tokens if exposure is suspected.",
"channels.telegram.capabilities.inlineButtons":
"Enable Telegram inline button components for supported command and interaction surfaces. Disable if your deployment needs plain-text-only compatibility behavior.",
"channels.telegram.execApprovals":
"Telegram-native exec approval routing and approver authorization. Enable this only when Telegram should act as an explicit exec-approval client for the selected bot account.",
"channels.telegram.execApprovals.enabled":
"Enable Telegram exec approvals for this account. When false or unset, Telegram messages/buttons cannot approve exec requests.",
"channels.telegram.execApprovals.approvers":
"Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs; prompts are only delivered to these approvers when target includes dm.",
"channels.telegram.execApprovals.agentFilter":
'Optional allowlist of agent IDs eligible for Telegram exec approvals, for example `["main", "ops-agent"]`. Use this to keep approval prompts scoped to the agents you actually operate from Telegram.',
"channels.telegram.execApprovals.sessionFilter":
"Optional session-key filters matched as substring or regex-style patterns before Telegram approval routing is used. Use narrow patterns so Telegram approvals only appear for intended sessions.",
"channels.telegram.execApprovals.target":
'Controls where Telegram approval prompts are sent: "dm" sends to approver DMs (default), "channel" sends to the originating Telegram chat/topic, and "both" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted groups/topics.',
"channels.slack.configWrites":
"Allow Slack to write config in response to channel events/commands (default: true).",
"channels.slack.botToken":

View File

@@ -719,6 +719,12 @@ export const FIELD_LABELS: Record<string, string> = {
"channels.telegram.network.autoSelectFamily": "Telegram autoSelectFamily",
"channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)",
"channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons",
"channels.telegram.execApprovals": "Telegram Exec Approvals",
"channels.telegram.execApprovals.enabled": "Telegram Exec Approvals Enabled",
"channels.telegram.execApprovals.approvers": "Telegram Exec Approval Approvers",
"channels.telegram.execApprovals.agentFilter": "Telegram Exec Approval Agent Filter",
"channels.telegram.execApprovals.sessionFilter": "Telegram Exec Approval Session Filter",
"channels.telegram.execApprovals.target": "Telegram Exec Approval Target",
"channels.telegram.threadBindings.enabled": "Telegram Thread Binding Enabled",
"channels.telegram.threadBindings.idleHours": "Telegram Thread Binding Idle Timeout (hours)",
"channels.telegram.threadBindings.maxAgeHours": "Telegram Thread Binding Max Age (hours)",

View File

@@ -38,6 +38,20 @@ export type TelegramNetworkConfig = {
export type TelegramInlineButtonsScope = "off" | "dm" | "group" | "all" | "allowlist";
export type TelegramStreamingMode = "off" | "partial" | "block" | "progress";
export type TelegramExecApprovalTarget = "dm" | "channel" | "both";
export type TelegramExecApprovalConfig = {
/** Enable Telegram exec approvals for this account. Default: false. */
enabled?: boolean;
/** Telegram user IDs allowed to approve exec requests. Required if enabled. */
approvers?: Array<string | number>;
/** Only forward approvals for these agent IDs. Omit = all agents. */
agentFilter?: string[];
/** Only forward approvals matching these session key patterns (substring or regex). */
sessionFilter?: string[];
/** Where to send approval prompts. Default: "dm". */
target?: TelegramExecApprovalTarget;
};
export type TelegramCapabilitiesConfig =
| string[]
@@ -58,6 +72,8 @@ export type TelegramAccountConfig = {
name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: TelegramCapabilitiesConfig;
/** Telegram-native exec approval delivery + approver authorization. */
execApprovals?: TelegramExecApprovalConfig;
/** Markdown formatting overrides (tables). */
markdown?: MarkdownConfig;
/** Override native command registration for Telegram (bool or "auto"). */

View File

@@ -49,6 +49,7 @@ const DiscordIdSchema = z
const DiscordIdListSchema = z.array(DiscordIdSchema);
const TelegramInlineButtonsScopeSchema = z.enum(["off", "dm", "group", "all", "allowlist"]);
const TelegramIdListSchema = z.array(z.union([z.string(), z.number()]));
const TelegramCapabilitiesSchema = z.union([
z.array(z.string()),
@@ -153,6 +154,16 @@ export const TelegramAccountSchemaBase = z
.object({
name: z.string().optional(),
capabilities: TelegramCapabilitiesSchema.optional(),
execApprovals: z
.object({
enabled: z.boolean().optional(),
approvers: TelegramIdListSchema.optional(),
agentFilter: z.array(z.string()).optional(),
sessionFilter: z.array(z.string()).optional(),
target: z.enum(["dm", "channel", "both"]).optional(),
})
.strict()
.optional(),
markdown: MarkdownConfigSchema,
enabled: z.boolean().optional(),
commands: ProviderCommandsSchema,

View File

@@ -0,0 +1,23 @@
import type { ReplyPayload } from "../auto-reply/types.js";
import type { OpenClawConfig } from "../config/config.js";
import { getExecApprovalReplyMetadata } from "../infra/exec-approval-reply.js";
import { resolveDiscordAccount } from "./accounts.js";
export function isDiscordExecApprovalClientEnabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): boolean {
const config = resolveDiscordAccount(params).config.execApprovals;
return Boolean(config?.enabled && (config.approvers?.length ?? 0) > 0);
}
export function shouldSuppressLocalDiscordExecApprovalPrompt(params: {
cfg: OpenClawConfig;
accountId?: string | null;
payload: ReplyPayload;
}): boolean {
return (
isDiscordExecApprovalClientEnabled(params) &&
getExecApprovalReplyMetadata(params.payload) !== null
);
}

View File

@@ -470,15 +470,15 @@ describe("ExecApprovalButton", () => {
function createMockInteraction(userId: string) {
const reply = vi.fn().mockResolvedValue(undefined);
const update = vi.fn().mockResolvedValue(undefined);
const acknowledge = vi.fn().mockResolvedValue(undefined);
const followUp = vi.fn().mockResolvedValue(undefined);
const interaction = {
userId,
reply,
update,
acknowledge,
followUp,
} as unknown as ButtonInteraction;
return { interaction, reply, update, followUp };
return { interaction, reply, acknowledge, followUp };
}
it("denies unauthorized users with ephemeral message", async () => {
@@ -486,7 +486,7 @@ describe("ExecApprovalButton", () => {
const ctx: ExecApprovalButtonContext = { handler };
const button = new ExecApprovalButton(ctx);
const { interaction, reply, update } = createMockInteraction("999");
const { interaction, reply, acknowledge } = createMockInteraction("999");
const data: ComponentData = { id: "test-approval", action: "allow-once" };
await button.run(interaction, data);
@@ -495,7 +495,7 @@ describe("ExecApprovalButton", () => {
content: "⛔ You are not authorized to approve exec requests.",
ephemeral: true,
});
expect(update).not.toHaveBeenCalled();
expect(acknowledge).not.toHaveBeenCalled();
// oxlint-disable-next-line typescript/unbound-method -- vi.fn() mock
expect(handler.resolveApproval).not.toHaveBeenCalled();
});
@@ -505,50 +505,45 @@ describe("ExecApprovalButton", () => {
const ctx: ExecApprovalButtonContext = { handler };
const button = new ExecApprovalButton(ctx);
const { interaction, reply, update } = createMockInteraction("222");
const { interaction, reply, acknowledge } = createMockInteraction("222");
const data: ComponentData = { id: "test-approval", action: "allow-once" };
await button.run(interaction, data);
expect(reply).not.toHaveBeenCalled();
expect(update).toHaveBeenCalledWith({
content: "Submitting decision: **Allowed (once)**...",
components: [],
});
expect(acknowledge).toHaveBeenCalledTimes(1);
// oxlint-disable-next-line typescript/unbound-method -- vi.fn() mock
expect(handler.resolveApproval).toHaveBeenCalledWith("test-approval", "allow-once");
});
it("shows correct label for allow-always", async () => {
it("acknowledges allow-always interactions before resolving", async () => {
const handler = createMockHandler(["111"]);
const ctx: ExecApprovalButtonContext = { handler };
const button = new ExecApprovalButton(ctx);
const { interaction, update } = createMockInteraction("111");
const { interaction, acknowledge } = createMockInteraction("111");
const data: ComponentData = { id: "test-approval", action: "allow-always" };
await button.run(interaction, data);
expect(update).toHaveBeenCalledWith({
content: "Submitting decision: **Allowed (always)**...",
components: [],
});
expect(acknowledge).toHaveBeenCalledTimes(1);
// oxlint-disable-next-line typescript/unbound-method -- vi.fn() mock
expect(handler.resolveApproval).toHaveBeenCalledWith("test-approval", "allow-always");
});
it("shows correct label for deny", async () => {
it("acknowledges deny interactions before resolving", async () => {
const handler = createMockHandler(["111"]);
const ctx: ExecApprovalButtonContext = { handler };
const button = new ExecApprovalButton(ctx);
const { interaction, update } = createMockInteraction("111");
const { interaction, acknowledge } = createMockInteraction("111");
const data: ComponentData = { id: "test-approval", action: "deny" };
await button.run(interaction, data);
expect(update).toHaveBeenCalledWith({
content: "Submitting decision: **Denied**...",
components: [],
});
expect(acknowledge).toHaveBeenCalledTimes(1);
// oxlint-disable-next-line typescript/unbound-method -- vi.fn() mock
expect(handler.resolveApproval).toHaveBeenCalledWith("test-approval", "deny");
});
it("handles invalid data gracefully", async () => {
@@ -556,18 +551,20 @@ describe("ExecApprovalButton", () => {
const ctx: ExecApprovalButtonContext = { handler };
const button = new ExecApprovalButton(ctx);
const { interaction, update } = createMockInteraction("111");
const { interaction, acknowledge, reply } = createMockInteraction("111");
const data: ComponentData = { id: "", action: "invalid" };
await button.run(interaction, data);
expect(update).toHaveBeenCalledWith({
expect(reply).toHaveBeenCalledWith({
content: "This approval is no longer valid.",
components: [],
ephemeral: true,
});
expect(acknowledge).not.toHaveBeenCalled();
// oxlint-disable-next-line typescript/unbound-method -- vi.fn() mock
expect(handler.resolveApproval).not.toHaveBeenCalled();
});
it("follows up with error when resolve fails", async () => {
const handler = createMockHandler(["111"]);
handler.resolveApproval = vi.fn().mockResolvedValue(false);
@@ -581,7 +578,7 @@ describe("ExecApprovalButton", () => {
expect(followUp).toHaveBeenCalledWith({
content:
"Failed to submit approval decision. The request may have expired or already been resolved.",
"Failed to submit approval decision for **Allowed (once)**. The request may have expired or already been resolved.",
ephemeral: true,
});
});
@@ -596,14 +593,14 @@ describe("ExecApprovalButton", () => {
const ctx: ExecApprovalButtonContext = { handler };
const button = new ExecApprovalButton(ctx);
const { interaction, update, reply } = createMockInteraction("111");
const { interaction, acknowledge, reply } = createMockInteraction("111");
const data: ComponentData = { id: "test-approval", action: "allow-once" };
await button.run(interaction, data);
// Should match because getApprovers returns [111] and button does String(id) === userId
expect(reply).not.toHaveBeenCalled();
expect(update).toHaveBeenCalled();
expect(acknowledge).toHaveBeenCalled();
});
});
@@ -803,6 +800,80 @@ describe("DiscordExecApprovalHandler delivery routing", () => {
clearPendingTimeouts(handler);
});
it("posts an in-channel note when target is dm and the request came from a non-DM discord conversation", async () => {
const handler = createHandler({
enabled: true,
approvers: ["123"],
target: "dm",
});
const internals = getHandlerInternals(handler);
mockRestPost.mockImplementation(
async (route: string, params?: { body?: { content?: string } }) => {
if (route === Routes.channelMessages("999888777")) {
expect(params?.body?.content).toContain("I sent the allowed approvers DMs");
return { id: "note-1", channel_id: "999888777" };
}
if (route === Routes.userChannels()) {
return { id: "dm-1" };
}
if (route === Routes.channelMessages("dm-1")) {
return { id: "msg-1", channel_id: "dm-1" };
}
throw new Error(`unexpected route: ${route}`);
},
);
await internals.handleApprovalRequested(createRequest());
expect(mockRestPost).toHaveBeenCalledWith(
Routes.channelMessages("999888777"),
expect.objectContaining({
body: expect.objectContaining({
content: expect.stringContaining("I sent the allowed approvers DMs"),
}),
}),
);
expect(mockRestPost).toHaveBeenCalledWith(
Routes.channelMessages("dm-1"),
expect.objectContaining({
body: expect.any(Object),
}),
);
clearPendingTimeouts(handler);
});
it("does not post an in-channel note when the request already came from a discord DM", async () => {
const handler = createHandler({
enabled: true,
approvers: ["123"],
target: "dm",
});
const internals = getHandlerInternals(handler);
mockRestPost.mockImplementation(async (route: string) => {
if (route === Routes.userChannels()) {
return { id: "dm-1" };
}
if (route === Routes.channelMessages("dm-1")) {
return { id: "msg-1", channel_id: "dm-1" };
}
throw new Error(`unexpected route: ${route}`);
});
await internals.handleApprovalRequested(
createRequest({ sessionKey: "agent:main:discord:dm:123" }),
);
expect(mockRestPost).not.toHaveBeenCalledWith(
Routes.channelMessages("999888777"),
expect.anything(),
);
clearPendingTimeouts(handler);
});
});
describe("DiscordExecApprovalHandler gateway auth resolution", () => {

View File

@@ -17,6 +17,7 @@ import { buildGatewayConnectionDetails } from "../../gateway/call.js";
import { GatewayClient } from "../../gateway/client.js";
import { resolveGatewayConnectionAuth } from "../../gateway/connection-auth.js";
import type { EventFrame } from "../../gateway/protocol/index.js";
import { getExecApprovalApproverDmNoticeText } from "../../infra/exec-approval-reply.js";
import type {
ExecApprovalDecision,
ExecApprovalRequest,
@@ -47,6 +48,12 @@ export function extractDiscordChannelId(sessionKey?: string | null): string | nu
return match ? match[1] : null;
}
function buildDiscordApprovalDmRedirectNotice(): { content: string } {
return {
content: getExecApprovalApproverDmNoticeText(),
};
}
type PendingApproval = {
discordMessageId: string;
discordChannelId: string;
@@ -498,6 +505,24 @@ export class DiscordExecApprovalHandler {
const sendToDm = target === "dm" || target === "both";
const sendToChannel = target === "channel" || target === "both";
let fallbackToDm = false;
const originatingChannelId =
request.request.sessionKey && target === "dm"
? extractDiscordChannelId(request.request.sessionKey)
: null;
if (target === "dm" && originatingChannelId) {
try {
await discordRequest(
() =>
rest.post(Routes.channelMessages(originatingChannelId), {
body: buildDiscordApprovalDmRedirectNotice(),
}) as Promise<{ id: string; channel_id: string }>,
"send-approval-dm-redirect-notice",
);
} catch (err) {
logError(`discord exec approvals: failed to send DM redirect notice: ${String(err)}`);
}
}
// Send to originating channel if configured
if (sendToChannel) {
@@ -768,9 +793,9 @@ export class ExecApprovalButton extends Button {
const parsed = parseExecApprovalData(data);
if (!parsed) {
try {
await interaction.update({
await interaction.reply({
content: "This approval is no longer valid.",
components: [],
ephemeral: true,
});
} catch {
// Interaction may have expired
@@ -800,12 +825,11 @@ export class ExecApprovalButton extends Button {
? "Allowed (always)"
: "Denied";
// Update the message immediately to show the decision
// Acknowledge immediately so Discord does not fail the interaction while
// the gateway resolve roundtrip completes. The resolved event will update
// the approval card in-place with the final state.
try {
await interaction.update({
content: `Submitting decision: **${decisionLabel}**...`,
components: [], // Remove buttons
});
await interaction.acknowledge();
} catch {
// Interaction may have expired, try to continue anyway
}
@@ -815,8 +839,7 @@ export class ExecApprovalButton extends Button {
if (!ok) {
try {
await interaction.followUp({
content:
"Failed to submit approval decision. The request may have expired or already been resolved.",
content: `Failed to submit approval decision for **${decisionLabel}**. The request may have expired or already been resolved.`,
ephemeral: true,
});
} catch {

View File

@@ -31,6 +31,11 @@ type PendingEntry = {
promise: Promise<ExecApprovalDecision | null>;
};
export type ExecApprovalIdLookupResult =
| { kind: "exact" | "prefix"; id: string }
| { kind: "ambiguous"; ids: string[] }
| { kind: "none" };
export class ExecApprovalManager {
private pending = new Map<string, PendingEntry>();
@@ -170,4 +175,37 @@ export class ExecApprovalManager {
const entry = this.pending.get(recordId);
return entry?.promise ?? null;
}
lookupPendingId(input: string): ExecApprovalIdLookupResult {
const normalized = input.trim();
if (!normalized) {
return { kind: "none" };
}
const exact = this.pending.get(normalized);
if (exact) {
return exact.record.resolvedAtMs === undefined
? { kind: "exact", id: normalized }
: { kind: "none" };
}
const lowerPrefix = normalized.toLowerCase();
const matches: string[] = [];
for (const [id, entry] of this.pending.entries()) {
if (entry.record.resolvedAtMs !== undefined) {
continue;
}
if (id.toLowerCase().startsWith(lowerPrefix)) {
matches.push(id);
}
}
if (matches.length === 1) {
return { kind: "prefix", id: matches[0] };
}
if (matches.length > 1) {
return { kind: "ambiguous", ids: matches };
}
return { kind: "none" };
}
}

View File

@@ -23,6 +23,7 @@ type SystemRunParamsLike = {
approved?: unknown;
approvalDecision?: unknown;
runId?: unknown;
suppressNotifyOnExit?: unknown;
};
type ApprovalLookup = {
@@ -78,6 +79,7 @@ function pickSystemRunParams(raw: Record<string, unknown>): Record<string, unkno
"agentId",
"sessionKey",
"runId",
"suppressNotifyOnExit",
]) {
if (key in raw) {
next[key] = raw[key];

View File

@@ -19,14 +19,6 @@ export function createExecApprovalHandlers(
manager: ExecApprovalManager,
opts?: { forwarder?: ExecApprovalForwarder },
): GatewayRequestHandlers {
const hasApprovalClients = (context: { hasExecApprovalClients?: () => boolean }) => {
if (typeof context.hasExecApprovalClients === "function") {
return context.hasExecApprovalClients();
}
// Fail closed when no operator-scope probe is available.
return false;
};
return {
"exec.approval.request": async ({ params, respond, context, client }) => {
if (!validateExecApprovalRequestParams(params)) {
@@ -178,10 +170,11 @@ export function createExecApprovalHandlers(
},
{ dropIfSlow: true },
);
let forwardedToTargets = false;
const hasExecApprovalClients = context.hasExecApprovalClients?.() ?? false;
let forwarded = false;
if (opts?.forwarder) {
try {
forwardedToTargets = await opts.forwarder.handleRequested({
forwarded = await opts.forwarder.handleRequested({
id: record.id,
request: record.request,
createdAtMs: record.createdAtMs,
@@ -192,8 +185,19 @@ export function createExecApprovalHandlers(
}
}
if (!hasApprovalClients(context) && !forwardedToTargets) {
manager.expire(record.id, "auto-expire:no-approver-clients");
if (!hasExecApprovalClients && !forwarded) {
manager.expire(record.id, "no-approval-route");
respond(
true,
{
id: record.id,
decision: null,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
},
undefined,
);
return;
}
// Only send immediate "accepted" response when twoPhase is requested.
@@ -275,21 +279,48 @@ export function createExecApprovalHandlers(
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid decision"));
return;
}
const snapshot = manager.getSnapshot(p.id);
const resolvedId = manager.lookupPendingId(p.id);
if (resolvedId.kind === "none") {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id"),
);
return;
}
if (resolvedId.kind === "ambiguous") {
const candidates = resolvedId.ids.slice(0, 3).join(", ");
const remainder = resolvedId.ids.length > 3 ? ` (+${resolvedId.ids.length - 3} more)` : "";
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`ambiguous approval id prefix; matches: ${candidates}${remainder}. Use the full id.`,
),
);
return;
}
const approvalId = resolvedId.id;
const snapshot = manager.getSnapshot(approvalId);
const resolvedBy = client?.connect?.client?.displayName ?? client?.connect?.client?.id;
const ok = manager.resolve(p.id, decision, resolvedBy ?? null);
const ok = manager.resolve(approvalId, decision, resolvedBy ?? null);
if (!ok) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown approval id"));
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id"),
);
return;
}
context.broadcast(
"exec.approval.resolved",
{ id: p.id, decision, resolvedBy, ts: Date.now(), request: snapshot?.request },
{ id: approvalId, decision, resolvedBy, ts: Date.now(), request: snapshot?.request },
{ dropIfSlow: true },
);
void opts?.forwarder
?.handleResolved({
id: p.id,
id: approvalId,
decision,
resolvedBy,
ts: Date.now(),

View File

@@ -531,6 +531,19 @@ describe("exec approval handlers", () => {
expect(broadcasts.some((entry) => entry.event === "exec.approval.resolved")).toBe(true);
});
it("does not reuse a resolved exact id as a prefix for another pending approval", () => {
const manager = new ExecApprovalManager();
const resolvedRecord = manager.create({ command: "echo old", host: "gateway" }, 2_000, "abc");
void manager.register(resolvedRecord, 2_000);
expect(manager.resolve("abc", "allow-once")).toBe(true);
const pendingRecord = manager.create({ command: "echo new", host: "gateway" }, 2_000, "abcdef");
void manager.register(pendingRecord, 2_000);
expect(manager.lookupPendingId("abc")).toEqual({ kind: "none" });
expect(manager.lookupPendingId("abcdef")).toEqual({ kind: "exact", id: "abcdef" });
});
it("stores versioned system.run binding and sorted env keys on approval request", async () => {
const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
await requestExecApproval({
@@ -666,6 +679,134 @@ describe("exec approval handlers", () => {
expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
});
it("accepts unique short approval id prefixes", async () => {
const manager = new ExecApprovalManager();
const handlers = createExecApprovalHandlers(manager);
const respond = vi.fn();
const context = {
broadcast: (_event: string, _payload: unknown) => {},
};
const record = manager.create({ command: "echo ok" }, 60_000, "approval-12345678-aaaa");
void manager.register(record, 60_000);
await resolveExecApproval({
handlers,
id: "approval-1234",
respond,
context,
});
expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined);
expect(manager.getSnapshot(record.id)?.decision).toBe("allow-once");
});
it("rejects ambiguous short approval id prefixes", async () => {
const manager = new ExecApprovalManager();
const handlers = createExecApprovalHandlers(manager);
const respond = vi.fn();
const context = {
broadcast: (_event: string, _payload: unknown) => {},
};
void manager.register(
manager.create({ command: "echo one" }, 60_000, "approval-abcd-1111"),
60_000,
);
void manager.register(
manager.create({ command: "echo two" }, 60_000, "approval-abcd-2222"),
60_000,
);
await resolveExecApproval({
handlers,
id: "approval-abcd",
respond,
context,
});
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
message: expect.stringContaining("ambiguous approval id prefix"),
}),
);
});
it("returns deterministic unknown/expired message for missing approval ids", async () => {
const { handlers, respond, context } = createExecApprovalFixture();
await resolveExecApproval({
handlers,
id: "missing-approval-id",
respond,
context,
});
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
message: "unknown or expired approval id",
}),
);
});
it("resolves only the targeted approval id when multiple requests are pending", async () => {
const manager = new ExecApprovalManager();
const handlers = createExecApprovalHandlers(manager);
const context = {
broadcast: (_event: string, _payload: unknown) => {},
hasExecApprovalClients: () => true,
};
const respondOne = vi.fn();
const respondTwo = vi.fn();
const requestOne = requestExecApproval({
handlers,
respond: respondOne,
context,
params: { id: "approval-one", host: "gateway", timeoutMs: 60_000 },
});
const requestTwo = requestExecApproval({
handlers,
respond: respondTwo,
context,
params: { id: "approval-two", host: "gateway", timeoutMs: 60_000 },
});
await drainApprovalRequestTicks();
const resolveRespond = vi.fn();
await resolveExecApproval({
handlers,
id: "approval-one",
respond: resolveRespond,
context,
});
expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
expect(manager.getSnapshot("approval-one")?.decision).toBe("allow-once");
expect(manager.getSnapshot("approval-two")?.decision).toBeUndefined();
expect(manager.getSnapshot("approval-two")?.resolvedAtMs).toBeUndefined();
expect(manager.expire("approval-two", "test-expire")).toBe(true);
await requestOne;
await requestTwo;
expect(respondOne).toHaveBeenCalledWith(
true,
expect.objectContaining({ id: "approval-one", decision: "allow-once" }),
undefined,
);
expect(respondTwo).toHaveBeenCalledWith(
true,
expect.objectContaining({ id: "approval-two", decision: null }),
undefined,
);
});
it("forwards turn-source metadata to exec approval forwarding", async () => {
vi.useFakeTimers();
try {
@@ -703,32 +844,59 @@ describe("exec approval handlers", () => {
}
});
it("expires immediately when no approver clients and no forwarding targets", async () => {
vi.useFakeTimers();
try {
const { manager, handlers, forwarder, respond, context } =
createForwardingExecApprovalFixture();
const expireSpy = vi.spyOn(manager, "expire");
it("fast-fails approvals when no approver clients and no forwarding targets", async () => {
const { manager, handlers, forwarder, respond, context } =
createForwardingExecApprovalFixture();
const expireSpy = vi.spyOn(manager, "expire");
const requestPromise = requestExecApproval({
handlers,
respond,
context,
params: { timeoutMs: 60_000 },
});
await drainApprovalRequestTicks();
expect(forwarder.handleRequested).toHaveBeenCalledTimes(1);
expect(expireSpy).toHaveBeenCalledTimes(1);
await vi.runOnlyPendingTimersAsync();
await requestPromise;
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({ decision: null }),
undefined,
);
} finally {
vi.useRealTimers();
}
await requestExecApproval({
handlers,
respond,
context,
params: { timeoutMs: 60_000, id: "approval-no-approver", host: "gateway" },
});
expect(forwarder.handleRequested).toHaveBeenCalledTimes(1);
expect(expireSpy).toHaveBeenCalledWith("approval-no-approver", "no-approval-route");
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({ id: "approval-no-approver", decision: null }),
undefined,
);
});
it("keeps approvals pending when no approver clients but forwarding accepted the request", async () => {
const { manager, handlers, forwarder, respond, context } =
createForwardingExecApprovalFixture();
const expireSpy = vi.spyOn(manager, "expire");
const resolveRespond = vi.fn();
forwarder.handleRequested.mockResolvedValueOnce(true);
const requestPromise = requestExecApproval({
handlers,
respond,
context,
params: { timeoutMs: 60_000, id: "approval-forwarded", host: "gateway" },
});
await drainApprovalRequestTicks();
expect(forwarder.handleRequested).toHaveBeenCalledTimes(1);
expect(expireSpy).not.toHaveBeenCalled();
await resolveExecApproval({
handlers,
id: "approval-forwarded",
respond: resolveRespond,
context,
});
await requestPromise;
expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({ id: "approval-forwarded", decision: "allow-once" }),
undefined,
);
});
});

View File

@@ -492,6 +492,23 @@ describe("notifications changed events", () => {
expect(enqueueSystemEventMock).toHaveBeenCalledTimes(2);
expect(requestHeartbeatNowMock).toHaveBeenCalledTimes(1);
});
it("suppresses exec notifyOnExit events when payload opts out", async () => {
const ctx = buildCtx();
await handleNodeEvent(ctx, "node-n7", {
event: "exec.finished",
payloadJSON: JSON.stringify({
sessionKey: "agent:main:main",
runId: "approval-1",
exitCode: 0,
output: "ok",
suppressNotifyOnExit: true,
}),
});
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
expect(requestHeartbeatNowMock).not.toHaveBeenCalled();
});
});
describe("agent request events", () => {

View File

@@ -538,6 +538,9 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
if (!notifyOnExit) {
return;
}
if (obj.suppressNotifyOnExit === true) {
return;
}
const runId = typeof obj.runId === "string" ? obj.runId.trim() : "";
const command = typeof obj.command === "string" ? obj.command.trim() : "";

View File

@@ -1,8 +1,11 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { telegramOutbound } from "../channels/plugins/outbound/telegram.js";
import type { OpenClawConfig } from "../config/config.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js";
import { createExecApprovalForwarder } from "./exec-approval-forwarder.js";
const baseRequest = {
@@ -18,8 +21,18 @@ const baseRequest = {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
const emptyRegistry = createTestRegistry([]);
const defaultRegistry = createTestRegistry([
{
pluginId: "telegram",
plugin: createOutboundTestPlugin({ id: "telegram", outbound: telegramOutbound }),
source: "test",
},
]);
function getFirstDeliveryText(deliver: ReturnType<typeof vi.fn>): string {
const firstCall = deliver.mock.calls[0]?.[0] as
| { payloads?: Array<{ text?: string }> }
@@ -32,7 +45,7 @@ const TARGETS_CFG = {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "telegram", to: "123" }],
targets: [{ channel: "slack", to: "U123" }],
},
},
} as OpenClawConfig;
@@ -128,6 +141,14 @@ async function expectSessionFilterRequestResult(params: {
}
describe("exec approval forwarder", () => {
beforeEach(() => {
setActivePluginRegistry(defaultRegistry);
});
afterEach(() => {
setActivePluginRegistry(emptyRegistry);
});
it("forwards to session target and resolves", async () => {
vi.useFakeTimers();
const cfg = {
@@ -159,19 +180,118 @@ describe("exec approval forwarder", () => {
const { deliver, forwarder } = createForwarder({ cfg: TARGETS_CFG });
await expect(forwarder.handleRequested(baseRequest)).resolves.toBe(true);
await Promise.resolve();
expect(deliver).toHaveBeenCalledTimes(1);
await vi.runAllTimersAsync();
expect(deliver).toHaveBeenCalledTimes(2);
});
it("skips telegram forwarding when telegram exec approvals handler is enabled", async () => {
vi.useFakeTimers();
const cfg = {
approvals: {
exec: {
enabled: true,
mode: "session",
},
},
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["123"],
target: "channel",
},
},
},
} as OpenClawConfig;
const { deliver, forwarder } = createForwarder({
cfg,
resolveSessionTarget: () => ({ channel: "telegram", to: "-100999", threadId: 77 }),
});
await expect(
forwarder.handleRequested({
...baseRequest,
request: {
...baseRequest.request,
turnSourceChannel: "telegram",
turnSourceTo: "-100999",
turnSourceThreadId: "77",
turnSourceAccountId: "default",
},
}),
).resolves.toBe(false);
expect(deliver).not.toHaveBeenCalled();
});
it("attaches explicit telegram buttons in forwarded telegram fallback payloads", async () => {
vi.useFakeTimers();
const cfg = {
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "telegram", to: "123" }],
},
},
} as OpenClawConfig;
const { deliver, forwarder } = createForwarder({ cfg });
await expect(
forwarder.handleRequested({
...baseRequest,
request: {
...baseRequest.request,
turnSourceChannel: "discord",
turnSourceTo: "channel:123",
},
}),
).resolves.toBe(true);
expect(deliver).toHaveBeenCalledTimes(1);
expect(deliver).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
to: "123",
payloads: [
expect.objectContaining({
channelData: {
execApproval: expect.objectContaining({
approvalId: "req-1",
}),
telegram: {
buttons: [
[
{ text: "Allow Once", callback_data: "/approve req-1 allow-once" },
{ text: "Allow Always", callback_data: "/approve req-1 allow-always" },
],
[{ text: "Deny", callback_data: "/approve req-1 deny" }],
],
},
},
}),
],
}),
);
});
it("formats single-line commands as inline code", async () => {
vi.useFakeTimers();
const { deliver, forwarder } = createForwarder({ cfg: TARGETS_CFG });
await expect(forwarder.handleRequested(baseRequest)).resolves.toBe(true);
await Promise.resolve();
expect(getFirstDeliveryText(deliver)).toContain("Command: `echo hello`");
const text = getFirstDeliveryText(deliver);
expect(text).toContain("🔒 Exec approval required");
expect(text).toContain("Command: `echo hello`");
expect(text).toContain("Expires in: 5s");
expect(text).toContain("Reply with: /approve <id> allow-once|allow-always|deny");
});
it("formats complex commands as fenced code blocks", async () => {
@@ -187,8 +307,9 @@ describe("exec approval forwarder", () => {
},
}),
).resolves.toBe(true);
await Promise.resolve();
expect(getFirstDeliveryText(deliver)).toContain("Command:\n```\necho `uname`\necho done\n```");
expect(getFirstDeliveryText(deliver)).toContain("```\necho `uname`\necho done\n```");
});
it("returns false when forwarding is disabled", async () => {
@@ -334,7 +455,8 @@ describe("exec approval forwarder", () => {
},
}),
).resolves.toBe(true);
await Promise.resolve();
expect(getFirstDeliveryText(deliver)).toContain("Command:\n````\necho ```danger```\n````");
expect(getFirstDeliveryText(deliver)).toContain("````\necho ```danger```\n````");
});
});

View File

@@ -1,3 +1,4 @@
import type { ReplyPayload } from "../auto-reply/types.js";
import type { OpenClawConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
@@ -8,11 +9,14 @@ import type {
import { createSubsystemLogger } from "../logging/subsystem.js";
import { normalizeAccountId, parseAgentSessionKey } from "../routing/session-key.js";
import { compileSafeRegex, testRegexWithBoundedInput } from "../security/safe-regex.js";
import { buildTelegramExecApprovalButtons } from "../telegram/approval-buttons.js";
import { sendTypingTelegram } from "../telegram/send.js";
import {
isDeliverableMessageChannel,
normalizeMessageChannel,
type DeliverableMessageChannel,
} from "../utils/message-channel.js";
import { buildExecApprovalPendingReplyPayload } from "./exec-approval-reply.js";
import type {
ExecApprovalDecision,
ExecApprovalRequest,
@@ -65,7 +69,11 @@ function matchSessionFilter(sessionKey: string, patterns: string[]): boolean {
}
function shouldForward(params: {
config?: ExecApprovalForwardingConfig;
config?: {
enabled?: boolean;
agentFilter?: string[];
sessionFilter?: string[];
};
request: ExecApprovalRequest;
}): boolean {
const config = params.config;
@@ -147,6 +155,48 @@ function shouldSkipDiscordForwarding(
return Boolean(execApprovals?.enabled && (execApprovals.approvers?.length ?? 0) > 0);
}
function shouldSkipTelegramForwarding(params: {
target: ExecApprovalForwardTarget;
cfg: OpenClawConfig;
request: ExecApprovalRequest;
}): boolean {
const channel = normalizeMessageChannel(params.target.channel) ?? params.target.channel;
if (channel !== "telegram") {
return false;
}
const requestChannel = normalizeMessageChannel(params.request.request.turnSourceChannel ?? "");
if (requestChannel !== "telegram") {
return false;
}
const telegram = params.cfg.channels?.telegram;
if (!telegram) {
return false;
}
const telegramConfig = telegram as
| {
execApprovals?: { enabled?: boolean; approvers?: Array<string | number> };
accounts?: Record<
string,
{ execApprovals?: { enabled?: boolean; approvers?: Array<string | number> } }
>;
}
| undefined;
if (!telegramConfig) {
return false;
}
const accountId =
params.target.accountId?.trim() || params.request.request.turnSourceAccountId?.trim();
const account = accountId
? (resolveChannelAccountConfig<{
execApprovals?: { enabled?: boolean; approvers?: Array<string | number> };
}>(telegramConfig.accounts, accountId) as
| { execApprovals?: { enabled?: boolean; approvers?: Array<string | number> } }
| undefined)
: undefined;
const execApprovals = account?.execApprovals ?? telegramConfig.execApprovals;
return Boolean(execApprovals?.enabled && (execApprovals.approvers?.length ?? 0) > 0);
}
function formatApprovalCommand(command: string): { inline: boolean; text: string } {
if (!command.includes("\n") && !command.includes("`")) {
return { inline: true, text: `\`${command}\`` };
@@ -191,6 +241,10 @@ function buildRequestMessage(request: ExecApprovalRequest, nowMs: number) {
}
const expiresIn = Math.max(0, Math.round((request.expiresAtMs - nowMs) / 1000));
lines.push(`Expires in: ${expiresIn}s`);
lines.push("Mode: foreground (interactive approvals available in this chat).");
lines.push(
"Background mode note: non-interactive runs cannot wait for chat approvals; use pre-approved policy (allow-always or ask=off).",
);
lines.push("Reply with: /approve <id> allow-once|allow-always|deny");
return lines.join("\n");
}
@@ -261,7 +315,7 @@ function defaultResolveSessionTarget(params: {
async function deliverToTargets(params: {
cfg: OpenClawConfig;
targets: ForwardTarget[];
text: string;
buildPayload: (target: ForwardTarget) => ReplyPayload;
deliver: typeof deliverOutboundPayloads;
shouldSend?: () => boolean;
}) {
@@ -274,13 +328,33 @@ async function deliverToTargets(params: {
return;
}
try {
const payload = params.buildPayload(target);
if (
channel === "telegram" &&
payload.channelData &&
typeof payload.channelData === "object" &&
!Array.isArray(payload.channelData) &&
payload.channelData.execApproval
) {
const threadId =
typeof target.threadId === "number"
? target.threadId
: typeof target.threadId === "string"
? Number.parseInt(target.threadId, 10)
: undefined;
await sendTypingTelegram(target.to, {
cfg: params.cfg,
accountId: target.accountId,
...(Number.isFinite(threadId) ? { messageThreadId: threadId } : {}),
}).catch(() => {});
}
await params.deliver({
cfg: params.cfg,
channel,
to: target.to,
accountId: target.accountId,
threadId: target.threadId,
payloads: [{ text: params.text }],
payloads: [payload],
});
} catch (err) {
log.error(`exec approvals: failed to deliver to ${channel}:${target.to}: ${String(err)}`);
@@ -289,6 +363,42 @@ async function deliverToTargets(params: {
await Promise.allSettled(deliveries);
}
function buildRequestPayloadForTarget(
_cfg: OpenClawConfig,
request: ExecApprovalRequest,
nowMsValue: number,
target: ForwardTarget,
): ReplyPayload {
const channel = normalizeMessageChannel(target.channel) ?? target.channel;
if (channel === "telegram") {
const payload = buildExecApprovalPendingReplyPayload({
approvalId: request.id,
approvalSlug: request.id.slice(0, 8),
approvalCommandId: request.id,
command: request.request.command,
cwd: request.request.cwd ?? undefined,
host: request.request.host === "node" ? "node" : "gateway",
nodeId: request.request.nodeId ?? undefined,
expiresAtMs: request.expiresAtMs,
nowMs: nowMsValue,
});
const buttons = buildTelegramExecApprovalButtons(request.id);
if (!buttons) {
return payload;
}
return {
...payload,
channelData: {
...payload.channelData,
telegram: {
buttons,
},
},
};
}
return { text: buildRequestMessage(request, nowMsValue) };
}
function resolveForwardTargets(params: {
cfg: OpenClawConfig;
config?: ExecApprovalForwardingConfig;
@@ -343,15 +453,20 @@ export function createExecApprovalForwarder(
const handleRequested = async (request: ExecApprovalRequest): Promise<boolean> => {
const cfg = getConfig();
const config = cfg.approvals?.exec;
if (!shouldForward({ config, request })) {
return false;
}
const filteredTargets = resolveForwardTargets({
cfg,
config,
request,
resolveSessionTarget,
}).filter((target) => !shouldSkipDiscordForwarding(target, cfg));
const filteredTargets = [
...(shouldForward({ config, request })
? resolveForwardTargets({
cfg,
config,
request,
resolveSessionTarget,
})
: []),
].filter(
(target) =>
!shouldSkipDiscordForwarding(target, cfg) &&
!shouldSkipTelegramForwarding({ target, cfg, request }),
);
if (filteredTargets.length === 0) {
return false;
@@ -366,7 +481,12 @@ export function createExecApprovalForwarder(
}
pending.delete(request.id);
const expiredText = buildExpiredMessage(request);
await deliverToTargets({ cfg, targets: entry.targets, text: expiredText, deliver });
await deliverToTargets({
cfg,
targets: entry.targets,
buildPayload: () => ({ text: expiredText }),
deliver,
});
})();
}, expiresInMs);
timeoutId.unref?.();
@@ -377,12 +497,10 @@ export function createExecApprovalForwarder(
if (pending.get(request.id) !== pendingEntry) {
return false;
}
const text = buildRequestMessage(request, nowMs());
void deliverToTargets({
cfg,
targets: filteredTargets,
text,
buildPayload: (target) => buildRequestPayloadForTarget(cfg, request, nowMs(), target),
deliver,
shouldSend: () => pending.get(request.id) === pendingEntry,
}).catch((err) => {
@@ -410,20 +528,26 @@ export function createExecApprovalForwarder(
expiresAtMs: resolved.ts,
};
const config = cfg.approvals?.exec;
if (shouldForward({ config, request })) {
targets = resolveForwardTargets({
cfg,
config,
request,
resolveSessionTarget,
}).filter((target) => !shouldSkipDiscordForwarding(target, cfg));
}
targets = [
...(shouldForward({ config, request })
? resolveForwardTargets({
cfg,
config,
request,
resolveSessionTarget,
})
: []),
].filter(
(target) =>
!shouldSkipDiscordForwarding(target, cfg) &&
!shouldSkipTelegramForwarding({ target, cfg, request }),
);
}
if (!targets || targets.length === 0) {
return;
}
const text = buildResolvedMessage(resolved);
await deliverToTargets({ cfg, targets, text, deliver });
await deliverToTargets({ cfg, targets, buildPayload: () => ({ text }), deliver });
};
const stop = () => {

View File

@@ -0,0 +1,172 @@
import type { ReplyPayload } from "../auto-reply/types.js";
import type { ExecHost } from "./exec-approvals.js";
export type ExecApprovalReplyDecision = "allow-once" | "allow-always" | "deny";
export type ExecApprovalUnavailableReason =
| "initiating-platform-disabled"
| "initiating-platform-unsupported"
| "no-approval-route";
export type ExecApprovalReplyMetadata = {
approvalId: string;
approvalSlug: string;
allowedDecisions?: readonly ExecApprovalReplyDecision[];
};
export type ExecApprovalPendingReplyParams = {
warningText?: string;
approvalId: string;
approvalSlug: string;
approvalCommandId?: string;
command: string;
cwd?: string;
host: ExecHost;
nodeId?: string;
expiresAtMs?: number;
nowMs?: number;
};
export type ExecApprovalUnavailableReplyParams = {
warningText?: string;
channelLabel?: string;
reason: ExecApprovalUnavailableReason;
sentApproverDms?: boolean;
};
export function getExecApprovalApproverDmNoticeText(): string {
return "Approval required. I sent the allowed approvers DMs.";
}
function buildFence(text: string, language?: string): string {
let fence = "```";
while (text.includes(fence)) {
fence += "`";
}
const languagePrefix = language ? language : "";
return `${fence}${languagePrefix}\n${text}\n${fence}`;
}
export function getExecApprovalReplyMetadata(
payload: ReplyPayload,
): ExecApprovalReplyMetadata | null {
const channelData = payload.channelData;
if (!channelData || typeof channelData !== "object" || Array.isArray(channelData)) {
return null;
}
const execApproval = channelData.execApproval;
if (!execApproval || typeof execApproval !== "object" || Array.isArray(execApproval)) {
return null;
}
const record = execApproval as Record<string, unknown>;
const approvalId = typeof record.approvalId === "string" ? record.approvalId.trim() : "";
const approvalSlug = typeof record.approvalSlug === "string" ? record.approvalSlug.trim() : "";
if (!approvalId || !approvalSlug) {
return null;
}
const allowedDecisions = Array.isArray(record.allowedDecisions)
? record.allowedDecisions.filter(
(value): value is ExecApprovalReplyDecision =>
value === "allow-once" || value === "allow-always" || value === "deny",
)
: undefined;
return {
approvalId,
approvalSlug,
allowedDecisions,
};
}
export function buildExecApprovalPendingReplyPayload(
params: ExecApprovalPendingReplyParams,
): ReplyPayload {
const approvalCommandId = params.approvalCommandId?.trim() || params.approvalSlug;
const lines: string[] = [];
const warningText = params.warningText?.trim();
if (warningText) {
lines.push(warningText, "");
}
lines.push("Approval required.");
lines.push("Run:");
lines.push(buildFence(`/approve ${approvalCommandId} allow-once`, "txt"));
lines.push("Pending command:");
lines.push(buildFence(params.command, "sh"));
lines.push("Other options:");
lines.push(
buildFence(
`/approve ${approvalCommandId} allow-always\n/approve ${approvalCommandId} deny`,
"txt",
),
);
const info: string[] = [];
info.push(`Host: ${params.host}`);
if (params.nodeId) {
info.push(`Node: ${params.nodeId}`);
}
if (params.cwd) {
info.push(`CWD: ${params.cwd}`);
}
if (typeof params.expiresAtMs === "number" && Number.isFinite(params.expiresAtMs)) {
const expiresInSec = Math.max(
0,
Math.round((params.expiresAtMs - (params.nowMs ?? Date.now())) / 1000),
);
info.push(`Expires in: ${expiresInSec}s`);
}
info.push(`Full id: \`${params.approvalId}\``);
lines.push(info.join("\n"));
return {
text: lines.join("\n\n"),
channelData: {
execApproval: {
approvalId: params.approvalId,
approvalSlug: params.approvalSlug,
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
},
};
}
export function buildExecApprovalUnavailableReplyPayload(
params: ExecApprovalUnavailableReplyParams,
): ReplyPayload {
const lines: string[] = [];
const warningText = params.warningText?.trim();
if (warningText) {
lines.push(warningText, "");
}
if (params.sentApproverDms) {
lines.push(getExecApprovalApproverDmNoticeText());
return {
text: lines.join("\n\n"),
};
}
if (params.reason === "initiating-platform-disabled") {
lines.push(
`Exec approval is required, but chat exec approvals are not enabled on ${params.channelLabel ?? "this platform"}.`,
);
lines.push(
"Approve it from the Web UI or terminal UI, or from Discord or Telegram if those approval clients are enabled.",
);
} else if (params.reason === "initiating-platform-unsupported") {
lines.push(
`Exec approval is required, but ${params.channelLabel ?? "this platform"} does not support chat exec approvals.`,
);
lines.push(
"Approve it from the Web UI or terminal UI, or from Discord or Telegram if those approval clients are enabled.",
);
} else {
lines.push(
"Exec approval is required, but no interactive approval client is currently available.",
);
lines.push(
"Open the Web UI or terminal UI, or enable Discord or Telegram exec approvals, then retry the command.",
);
}
return {
text: lines.join("\n\n"),
};
}

View File

@@ -0,0 +1,77 @@
import { loadConfig, type OpenClawConfig } from "../config/config.js";
import { listEnabledDiscordAccounts } from "../discord/accounts.js";
import { isDiscordExecApprovalClientEnabled } from "../discord/exec-approvals.js";
import { listEnabledTelegramAccounts } from "../telegram/accounts.js";
import { isTelegramExecApprovalClientEnabled } from "../telegram/exec-approvals.js";
import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../utils/message-channel.js";
export type ExecApprovalInitiatingSurfaceState =
| { kind: "enabled"; channel: string | undefined; channelLabel: string }
| { kind: "disabled"; channel: string; channelLabel: string }
| { kind: "unsupported"; channel: string; channelLabel: string };
function labelForChannel(channel?: string): string {
switch (channel) {
case "discord":
return "Discord";
case "telegram":
return "Telegram";
case "tui":
return "terminal UI";
case INTERNAL_MESSAGE_CHANNEL:
return "Web UI";
default:
return channel ? channel[0]?.toUpperCase() + channel.slice(1) : "this platform";
}
}
export function resolveExecApprovalInitiatingSurfaceState(params: {
channel?: string | null;
accountId?: string | null;
cfg?: OpenClawConfig;
}): ExecApprovalInitiatingSurfaceState {
const channel = normalizeMessageChannel(params.channel);
const channelLabel = labelForChannel(channel);
if (!channel || channel === INTERNAL_MESSAGE_CHANNEL || channel === "tui") {
return { kind: "enabled", channel, channelLabel };
}
const cfg = params.cfg ?? loadConfig();
if (channel === "telegram") {
return isTelegramExecApprovalClientEnabled({ cfg, accountId: params.accountId })
? { kind: "enabled", channel, channelLabel }
: { kind: "disabled", channel, channelLabel };
}
if (channel === "discord") {
return isDiscordExecApprovalClientEnabled({ cfg, accountId: params.accountId })
? { kind: "enabled", channel, channelLabel }
: { kind: "disabled", channel, channelLabel };
}
return { kind: "unsupported", channel, channelLabel };
}
export function hasConfiguredExecApprovalDmRoute(cfg: OpenClawConfig): boolean {
for (const account of listEnabledDiscordAccounts(cfg)) {
const execApprovals = account.config.execApprovals;
if (!execApprovals?.enabled || (execApprovals.approvers?.length ?? 0) === 0) {
continue;
}
const target = execApprovals.target ?? "dm";
if (target === "dm" || target === "both") {
return true;
}
}
for (const account of listEnabledTelegramAccounts(cfg)) {
const execApprovals = account.config.execApprovals;
if (!execApprovals?.enabled || (execApprovals.approvers?.length ?? 0) === 0) {
continue;
}
const target = execApprovals.target ?? "dm";
if (target === "dm" || target === "both") {
return true;
}
}
return false;
}

View File

@@ -307,6 +307,75 @@ describe("deliverOutboundPayloads", () => {
);
});
it("does not inject telegram approval buttons from plain approval text", async () => {
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
await deliverTelegramPayload({
sendTelegram,
cfg: {
channels: {
telegram: {
botToken: "tok-1",
execApprovals: {
enabled: true,
approvers: ["123"],
target: "dm",
},
},
},
},
payload: {
text: "Mode: foreground\nRun: /approve 117ba06d allow-once (or allow-always / deny).",
},
});
const sendOpts = sendTelegram.mock.calls[0]?.[2] as { buttons?: unknown } | undefined;
expect(sendOpts?.buttons).toBeUndefined();
});
it("preserves explicit telegram buttons when sender path provides them", async () => {
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
const cfg: OpenClawConfig = {
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["123"],
target: "dm",
},
},
},
};
await deliverTelegramPayload({
sendTelegram,
cfg,
payload: {
text: "Approval required",
channelData: {
telegram: {
buttons: [
[
{ text: "Allow Once", callback_data: "/approve 117ba06d allow-once" },
{ text: "Allow Always", callback_data: "/approve 117ba06d allow-always" },
],
[{ text: "Deny", callback_data: "/approve 117ba06d deny" }],
],
},
},
},
});
const sendOpts = sendTelegram.mock.calls[0]?.[2] as { buttons?: unknown } | undefined;
expect(sendOpts?.buttons).toEqual([
[
{ text: "Allow Once", callback_data: "/approve 117ba06d allow-once" },
{ text: "Allow Always", callback_data: "/approve 117ba06d allow-always" },
],
[{ text: "Deny", callback_data: "/approve 117ba06d deny" }],
]);
});
it("scopes media local roots to the active agent workspace when agentId is provided", async () => {
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });

View File

@@ -300,6 +300,9 @@ function normalizePayloadForChannelDelivery(
function normalizePayloadsForChannelDelivery(
payloads: ReplyPayload[],
channel: Exclude<OutboundChannel, "none">,
_cfg: OpenClawConfig,
_to: string,
_accountId?: string,
): ReplyPayload[] {
const normalizedPayloads: ReplyPayload[] = [];
for (const payload of normalizeReplyPayloadsForDelivery(payloads)) {
@@ -307,10 +310,13 @@ function normalizePayloadsForChannelDelivery(
// Strip HTML tags for plain-text surfaces (WhatsApp, Signal, etc.)
// Models occasionally produce <br>, <b>, etc. that render as literal text.
// See https://github.com/openclaw/openclaw/issues/31884
if (isPlainTextSurface(channel) && payload.text) {
if (isPlainTextSurface(channel) && sanitizedPayload.text) {
// Telegram sendPayload uses textMode:"html". Preserve raw HTML in this path.
if (!(channel === "telegram" && payload.channelData)) {
sanitizedPayload = { ...payload, text: sanitizeForPlainText(payload.text) };
if (!(channel === "telegram" && sanitizedPayload.channelData)) {
sanitizedPayload = {
...sanitizedPayload,
text: sanitizeForPlainText(sanitizedPayload.text),
};
}
}
const normalized = normalizePayloadForChannelDelivery(sanitizedPayload, channel);
@@ -662,7 +668,13 @@ async function deliverOutboundPayloadsCore(
})),
};
};
const normalizedPayloads = normalizePayloadsForChannelDelivery(payloads, channel);
const normalizedPayloads = normalizePayloadsForChannelDelivery(
payloads,
channel,
cfg,
to,
accountId,
);
const hookRunner = getGlobalHookRunner();
const sessionKeyForInternalHooks = params.mirror?.sessionKey ?? params.session?.key;
const mirrorIsGroup = params.mirror?.isGroup;

View File

@@ -57,6 +57,7 @@ type SystemRunExecutionContext = {
sessionKey: string;
runId: string;
cmdText: string;
suppressNotifyOnExit: boolean;
};
type ResolvedExecApprovals = ReturnType<typeof resolveExecApprovals>;
@@ -77,6 +78,7 @@ type SystemRunParsePhase = {
timeoutMs: number | undefined;
needsScreenRecording: boolean;
approved: boolean;
suppressNotifyOnExit: boolean;
};
type SystemRunPolicyPhase = SystemRunParsePhase & {
@@ -167,6 +169,7 @@ async function sendSystemRunDenied(
host: "node",
command: execution.cmdText,
reason: params.reason,
suppressNotifyOnExit: execution.suppressNotifyOnExit,
}),
);
await opts.sendInvokeResult({
@@ -216,6 +219,7 @@ async function parseSystemRunPhase(
const agentId = opts.params.agentId?.trim() || undefined;
const sessionKey = opts.params.sessionKey?.trim() || "node";
const runId = opts.params.runId?.trim() || crypto.randomUUID();
const suppressNotifyOnExit = opts.params.suppressNotifyOnExit === true;
const envOverrides = sanitizeSystemRunEnvOverrides({
overrides: opts.params.env ?? undefined,
shellWrapper: shellCommand !== null,
@@ -228,7 +232,7 @@ async function parseSystemRunPhase(
agentId,
sessionKey,
runId,
execution: { sessionKey, runId, cmdText },
execution: { sessionKey, runId, cmdText, suppressNotifyOnExit },
approvalDecision: resolveExecApprovalDecision(opts.params.approvalDecision),
envOverrides,
env: opts.sanitizeEnv(envOverrides),
@@ -236,6 +240,7 @@ async function parseSystemRunPhase(
timeoutMs: opts.params.timeoutMs ?? undefined,
needsScreenRecording: opts.params.needsScreenRecording === true,
approved: opts.params.approved === true,
suppressNotifyOnExit,
};
}
@@ -434,6 +439,7 @@ async function executeSystemRunPhase(
runId: phase.runId,
cmdText: phase.cmdText,
result,
suppressNotifyOnExit: phase.suppressNotifyOnExit,
});
await opts.sendInvokeResult({
ok: true,
@@ -501,6 +507,7 @@ async function executeSystemRunPhase(
runId: phase.runId,
cmdText: phase.cmdText,
result,
suppressNotifyOnExit: phase.suppressNotifyOnExit,
});
await opts.sendInvokeResult({

View File

@@ -13,6 +13,7 @@ export type SystemRunParams = {
approved?: boolean | null;
approvalDecision?: string | null;
runId?: string | null;
suppressNotifyOnExit?: boolean | null;
};
export type RunResult = {
@@ -35,6 +36,7 @@ export type ExecEventPayload = {
success?: boolean;
output?: string;
reason?: string;
suppressNotifyOnExit?: boolean;
};
export type ExecFinishedResult = {
@@ -51,6 +53,7 @@ export type ExecFinishedEventParams = {
runId: string;
cmdText: string;
result: ExecFinishedResult;
suppressNotifyOnExit?: boolean;
};
export type SkillBinsProvider = {

View File

@@ -355,6 +355,7 @@ async function sendExecFinishedEvent(
timedOut: params.result.timedOut,
success: params.result.success,
output: combined,
suppressNotifyOnExit: params.suppressNotifyOnExit,
}),
);
}

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { buildTelegramExecApprovalButtons } from "./approval-buttons.js";
describe("telegram approval buttons", () => {
it("builds allow-once/allow-always/deny buttons", () => {
expect(buildTelegramExecApprovalButtons("fbd8daf7")).toEqual([
[
{ text: "Allow Once", callback_data: "/approve fbd8daf7 allow-once" },
{ text: "Allow Always", callback_data: "/approve fbd8daf7 allow-always" },
],
[{ text: "Deny", callback_data: "/approve fbd8daf7 deny" }],
]);
});
it("skips buttons when callback_data exceeds Telegram limit", () => {
expect(buildTelegramExecApprovalButtons(`a${"b".repeat(60)}`)).toBeUndefined();
});
});

View File

@@ -0,0 +1,42 @@
import type { ExecApprovalReplyDecision } from "../infra/exec-approval-reply.js";
import type { TelegramInlineButtons } from "./button-types.js";
const MAX_CALLBACK_DATA_BYTES = 64;
function fitsCallbackData(value: string): boolean {
return Buffer.byteLength(value, "utf8") <= MAX_CALLBACK_DATA_BYTES;
}
export function buildTelegramExecApprovalButtons(
approvalId: string,
): TelegramInlineButtons | undefined {
return buildTelegramExecApprovalButtonsForDecisions(approvalId, [
"allow-once",
"allow-always",
"deny",
]);
}
function buildTelegramExecApprovalButtonsForDecisions(
approvalId: string,
allowedDecisions: readonly ExecApprovalReplyDecision[],
): TelegramInlineButtons | undefined {
const allowOnce = `/approve ${approvalId} allow-once`;
if (!allowedDecisions.includes("allow-once") || !fitsCallbackData(allowOnce)) {
return undefined;
}
const primaryRow: Array<{ text: string; callback_data: string }> = [
{ text: "Allow Once", callback_data: allowOnce },
];
const allowAlways = `/approve ${approvalId} allow-always`;
if (allowedDecisions.includes("allow-always") && fitsCallbackData(allowAlways)) {
primaryRow.push({ text: "Allow Always", callback_data: allowAlways });
}
const rows: Array<Array<{ text: string; callback_data: string }>> = [primaryRow];
const deny = `/approve ${approvalId} deny`;
if (allowedDecisions.includes("deny") && fitsCallbackData(deny)) {
rows.push([{ text: "Deny", callback_data: deny }]);
}
return rows;
}

View File

@@ -57,6 +57,11 @@ import {
import type { TelegramContext } from "./bot/types.js";
import { resolveTelegramConversationRoute } from "./conversation-route.js";
import { enforceTelegramDmAccess } from "./dm-access.js";
import {
isTelegramExecApprovalApprover,
isTelegramExecApprovalClientEnabled,
shouldEnableTelegramExecApprovalButtons,
} from "./exec-approvals.js";
import {
evaluateTelegramGroupBaseAccess,
evaluateTelegramGroupPolicyAccess,
@@ -75,6 +80,9 @@ import {
import { buildInlineKeyboard } from "./send.js";
import { wasSentByBot } from "./sent-message-cache.js";
const APPROVE_CALLBACK_DATA_RE =
/^\/approve(?:@[^\s]+)?\s+[A-Za-z0-9][A-Za-z0-9._:-]*\s+(allow-once|allow-always|deny)\b/i;
function isMediaSizeLimitError(err: unknown): boolean {
const errMsg = String(err);
return errMsg.includes("exceeds") && errMsg.includes("MB limit");
@@ -1081,6 +1089,30 @@ export const registerTelegramHandlers = ({
params,
);
};
const clearCallbackButtons = async () => {
const emptyKeyboard = { inline_keyboard: [] };
const replyMarkup = { reply_markup: emptyKeyboard };
const editReplyMarkupFn = (ctx as { editMessageReplyMarkup?: unknown })
.editMessageReplyMarkup;
if (typeof editReplyMarkupFn === "function") {
return await ctx.editMessageReplyMarkup(replyMarkup);
}
const apiEditReplyMarkupFn = (bot.api as { editMessageReplyMarkup?: unknown })
.editMessageReplyMarkup;
if (typeof apiEditReplyMarkupFn === "function") {
return await bot.api.editMessageReplyMarkup(
callbackMessage.chat.id,
callbackMessage.message_id,
replyMarkup,
);
}
// Fallback path for older clients that do not expose editMessageReplyMarkup.
const messageText = callbackMessage.text ?? callbackMessage.caption;
if (typeof messageText !== "string" || messageText.trim().length === 0) {
return undefined;
}
return await editCallbackMessage(messageText, replyMarkup);
};
const deleteCallbackMessage = async () => {
const deleteFn = (ctx as { deleteMessage?: unknown }).deleteMessage;
if (typeof deleteFn === "function") {
@@ -1099,22 +1131,31 @@ export const registerTelegramHandlers = ({
return await bot.api.sendMessage(callbackMessage.chat.id, text, params);
};
const chatId = callbackMessage.chat.id;
const isGroup =
callbackMessage.chat.type === "group" || callbackMessage.chat.type === "supergroup";
const isApprovalCallback = APPROVE_CALLBACK_DATA_RE.test(data);
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
cfg,
accountId,
});
if (inlineButtonsScope === "off") {
return;
}
const chatId = callbackMessage.chat.id;
const isGroup =
callbackMessage.chat.type === "group" || callbackMessage.chat.type === "supergroup";
if (inlineButtonsScope === "dm" && isGroup) {
return;
}
if (inlineButtonsScope === "group" && !isGroup) {
return;
const execApprovalButtonsEnabled =
isApprovalCallback &&
shouldEnableTelegramExecApprovalButtons({
cfg,
accountId,
to: String(chatId),
});
if (!execApprovalButtonsEnabled) {
if (inlineButtonsScope === "off") {
return;
}
if (inlineButtonsScope === "dm" && isGroup) {
return;
}
if (inlineButtonsScope === "group" && !isGroup) {
return;
}
}
const messageThreadId = callbackMessage.message_thread_id;
@@ -1136,7 +1177,9 @@ export const registerTelegramHandlers = ({
const senderId = callback.from?.id ? String(callback.from.id) : "";
const senderUsername = callback.from?.username ?? "";
const authorizationMode: TelegramEventAuthorizationMode =
inlineButtonsScope === "allowlist" ? "callback-allowlist" : "callback-scope";
!execApprovalButtonsEnabled && inlineButtonsScope === "allowlist"
? "callback-allowlist"
: "callback-scope";
const senderAuthorization = authorizeTelegramEventSender({
chatId,
chatTitle: callbackMessage.chat.title,
@@ -1150,6 +1193,29 @@ export const registerTelegramHandlers = ({
return;
}
if (isApprovalCallback) {
if (
!isTelegramExecApprovalClientEnabled({ cfg, accountId }) ||
!isTelegramExecApprovalApprover({ cfg, accountId, senderId })
) {
logVerbose(
`Blocked telegram exec approval callback from ${senderId || "unknown"} (not an approver)`,
);
return;
}
try {
await clearCallbackButtons();
} catch (editErr) {
const errStr = String(editErr);
if (
!errStr.includes("message is not modified") &&
!errStr.includes("there is no text in the message to edit")
) {
logVerbose(`telegram: failed to clear approval callback buttons: ${errStr}`);
}
}
}
const paginationMatch = data.match(/^commands_page_(\d+|noop)(?::(.+))?$/);
if (paginationMatch) {
const pageValue = paginationMatch[1];

View File

@@ -202,6 +202,7 @@ export async function buildTelegramInboundContextPayload(params: {
SenderUsername: senderUsername || undefined,
Provider: "telegram",
Surface: "telegram",
BotUsername: primaryCtx.me?.username ?? undefined,
MessageSid: options?.messageIdOverride ?? String(msg.message_id),
ReplyToId: replyTarget?.id,
ReplyToBody: replyTarget?.body,

View File

@@ -140,6 +140,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
async function dispatchWithContext(params: {
context: TelegramMessageContext;
cfg?: Parameters<typeof dispatchTelegramMessage>[0]["cfg"];
telegramCfg?: Parameters<typeof dispatchTelegramMessage>[0]["telegramCfg"];
streamMode?: Parameters<typeof dispatchTelegramMessage>[0]["streamMode"];
bot?: Bot;
@@ -148,7 +149,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
await dispatchTelegramMessage({
context: params.context,
bot,
cfg: {},
cfg: params.cfg ?? {},
runtime: createRuntime(),
replyToMode: "first",
streamMode: params.streamMode ?? "partial",
@@ -211,6 +212,48 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(draftStream.clear).toHaveBeenCalledTimes(1);
});
it("does not inject approval buttons in local dispatch once the monitor owns approvals", async () => {
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver(
{
text: "Mode: foreground\nRun: /approve 117ba06d allow-once (or allow-always / deny).",
},
{ kind: "final" },
);
return { queuedFinal: true };
});
deliverReplies.mockResolvedValue({ delivered: true });
await dispatchWithContext({
context: createContext(),
streamMode: "off",
cfg: {
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["123"],
target: "dm",
},
},
},
},
});
expect(deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
replies: [
expect.objectContaining({
text: "Mode: foreground\nRun: /approve 117ba06d allow-once (or allow-always / deny).",
}),
],
}),
);
const deliveredPayload = (deliverReplies.mock.calls[0]?.[0] as { replies?: Array<unknown> })
?.replies?.[0] as { channelData?: unknown } | undefined;
expect(deliveredPayload?.channelData).toBeUndefined();
});
it("uses 30-char preview debounce for legacy block stream mode", async () => {
const draftStream = createDraftStream();
createTelegramDraftStream.mockReturnValue(draftStream);

View File

@@ -30,6 +30,7 @@ import { deliverReplies } from "./bot/delivery.js";
import type { TelegramStreamMode } from "./bot/types.js";
import type { TelegramInlineButtons } from "./button-types.js";
import { createTelegramDraftStream } from "./draft-stream.js";
import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js";
import { renderTelegramHtmlText } from "./format.js";
import {
type ArchivedPreview,
@@ -526,6 +527,16 @@ export const dispatchTelegramMessage = async ({
// rotations/partials are applied before final delivery mapping.
await enqueueDraftLaneEvent(async () => {});
}
if (
shouldSuppressLocalTelegramExecApprovalPrompt({
cfg,
accountId: route.accountId,
payload,
})
) {
queuedFinal = true;
return;
}
const previewButtons = (
payload.channelData?.telegram as { buttons?: TelegramInlineButtons } | undefined
)?.buttons;
@@ -559,7 +570,10 @@ export const dispatchTelegramMessage = async ({
info.kind === "final" &&
reasoningStepState.shouldBufferFinalAnswer()
) {
reasoningStepState.bufferFinalAnswer({ payload, text: segment.text });
reasoningStepState.bufferFinalAnswer({
payload,
text: segment.text,
});
continue;
}
if (segment.lane === "reasoning") {

View File

@@ -12,6 +12,20 @@ type ResolveConfiguredAcpBindingRecordFn =
typeof import("../acp/persistent-bindings.js").resolveConfiguredAcpBindingRecord;
type EnsureConfiguredAcpBindingSessionFn =
typeof import("../acp/persistent-bindings.js").ensureConfiguredAcpBindingSession;
type DispatchReplyWithBufferedBlockDispatcherFn =
typeof import("../auto-reply/reply/provider-dispatcher.js").dispatchReplyWithBufferedBlockDispatcher;
type DispatchReplyWithBufferedBlockDispatcherParams =
Parameters<DispatchReplyWithBufferedBlockDispatcherFn>[0];
type DispatchReplyWithBufferedBlockDispatcherResult = Awaited<
ReturnType<DispatchReplyWithBufferedBlockDispatcherFn>
>;
type DeliverRepliesFn = typeof import("./bot/delivery.js").deliverReplies;
type DeliverRepliesParams = Parameters<DeliverRepliesFn>[0];
const dispatchReplyResult: DispatchReplyWithBufferedBlockDispatcherResult = {
queuedFinal: false,
counts: {} as DispatchReplyWithBufferedBlockDispatcherResult["counts"],
};
const persistentBindingMocks = vi.hoisted(() => ({
resolveConfiguredAcpBindingRecord: vi.fn<ResolveConfiguredAcpBindingRecordFn>(() => null),
@@ -25,7 +39,12 @@ const sessionMocks = vi.hoisted(() => ({
resolveStorePath: vi.fn(),
}));
const replyMocks = vi.hoisted(() => ({
dispatchReplyWithBufferedBlockDispatcher: vi.fn(async () => undefined),
dispatchReplyWithBufferedBlockDispatcher: vi.fn<DispatchReplyWithBufferedBlockDispatcherFn>(
async () => dispatchReplyResult,
),
}));
const deliveryMocks = vi.hoisted(() => ({
deliverReplies: vi.fn<DeliverRepliesFn>(async () => ({ delivered: true })),
}));
const sessionBindingMocks = vi.hoisted(() => ({
resolveByConversation: vi.fn<
@@ -78,7 +97,7 @@ vi.mock("../plugins/commands.js", () => ({
executePluginCommand: vi.fn(async () => ({ text: "ok" })),
}));
vi.mock("./bot/delivery.js", () => ({
deliverReplies: vi.fn(async () => ({ delivered: true })),
deliverReplies: deliveryMocks.deliverReplies,
}));
function createDeferred<T>() {
@@ -263,9 +282,12 @@ describe("registerTelegramNativeCommands — session metadata", () => {
});
sessionMocks.recordSessionMetaFromInbound.mockClear().mockResolvedValue(undefined);
sessionMocks.resolveStorePath.mockClear().mockReturnValue("/tmp/openclaw-sessions.json");
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockClear().mockResolvedValue(undefined);
replyMocks.dispatchReplyWithBufferedBlockDispatcher
.mockClear()
.mockResolvedValue(dispatchReplyResult);
sessionBindingMocks.resolveByConversation.mockReset().mockReturnValue(null);
sessionBindingMocks.touch.mockReset();
deliveryMocks.deliverReplies.mockClear().mockResolvedValue({ delivered: true });
});
it("calls recordSessionMetaFromInbound after a native slash command", async () => {
@@ -303,6 +325,81 @@ describe("registerTelegramNativeCommands — session metadata", () => {
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
});
it("does not inject approval buttons for native command replies once the monitor owns approvals", async () => {
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(
async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => {
await dispatcherOptions.deliver(
{
text: "Mode: foreground\nRun: /approve 7f423fdc allow-once (or allow-always / deny).",
},
{ kind: "final" },
);
return dispatchReplyResult;
},
);
const { handler } = registerAndResolveStatusHandler({
cfg: {
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["12345"],
target: "dm",
},
},
},
},
});
await handler(buildStatusCommandContext());
const deliveredCall = deliveryMocks.deliverReplies.mock.calls[0]?.[0] as
| DeliverRepliesParams
| undefined;
const deliveredPayload = deliveredCall?.replies?.[0];
expect(deliveredPayload).toBeTruthy();
expect(deliveredPayload?.["text"]).toContain("/approve 7f423fdc allow-once");
expect(deliveredPayload?.["channelData"]).toBeUndefined();
});
it("suppresses local structured exec approval replies for native commands", async () => {
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(
async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => {
await dispatcherOptions.deliver(
{
text: "Approval required.\n\n```txt\n/approve 7f423fdc allow-once\n```",
channelData: {
execApproval: {
approvalId: "7f423fdc-1111-2222-3333-444444444444",
approvalSlug: "7f423fdc",
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
},
},
{ kind: "tool" },
);
return dispatchReplyResult;
},
);
const { handler } = registerAndResolveStatusHandler({
cfg: {
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["12345"],
target: "dm",
},
},
},
},
});
await handler(buildStatusCommandContext());
expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled();
});
it("routes Telegram native commands through configured ACP topic bindings", async () => {
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(

View File

@@ -64,6 +64,7 @@ import {
} from "./bot/helpers.js";
import type { TelegramContext } from "./bot/types.js";
import { resolveTelegramConversationRoute } from "./conversation-route.js";
import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js";
import {
evaluateTelegramGroupBaseAccess,
evaluateTelegramGroupPolicyAccess,
@@ -177,6 +178,7 @@ async function resolveTelegramCommandAuth(params: {
isForum,
messageThreadId,
});
const threadParams = buildTelegramThreadParams(threadSpec) ?? {};
const groupAllowContext = await resolveTelegramGroupAllowFromContext({
chatId,
accountId,
@@ -234,7 +236,6 @@ async function resolveTelegramCommandAuth(params: {
: null;
const sendAuthMessage = async (text: string) => {
const threadParams = buildTelegramThreadParams(threadSpec) ?? {};
await withTelegramApiErrorLogging({
operation: "sendMessage",
fn: () => bot.api.sendMessage(chatId, text, threadParams),
@@ -580,9 +581,8 @@ export const registerTelegramNativeCommands = ({
senderUsername,
groupConfig,
topicConfig,
commandAuthorized: initialCommandAuthorized,
commandAuthorized,
} = auth;
let commandAuthorized = initialCommandAuthorized;
const runtimeContext = await resolveCommandRuntimeContext({
msg,
isGroup,
@@ -751,6 +751,16 @@ export const registerTelegramNativeCommands = ({
dispatcherOptions: {
...prefixOptions,
deliver: async (payload, _info) => {
if (
shouldSuppressLocalTelegramExecApprovalPrompt({
cfg,
accountId: route.accountId,
payload,
})
) {
deliveryState.delivered = true;
return;
}
const result = await deliverReplies({
replies: [payload],
...deliveryBaseOptions,
@@ -863,10 +873,18 @@ export const registerTelegramNativeCommands = ({
messageThreadId: threadSpec.id,
});
await deliverReplies({
replies: [result],
...deliveryBaseOptions,
});
if (
!shouldSuppressLocalTelegramExecApprovalPrompt({
cfg,
accountId: route.accountId,
payload: result,
})
) {
await deliverReplies({
replies: [result],
...deliveryBaseOptions,
});
}
});
}
}

View File

@@ -111,6 +111,7 @@ export const botCtorSpy: AnyMock = vi.fn();
export const answerCallbackQuerySpy: AnyAsyncMock = vi.fn(async () => undefined);
export const sendChatActionSpy: AnyMock = vi.fn();
export const editMessageTextSpy: AnyAsyncMock = vi.fn(async () => ({ message_id: 88 }));
export const editMessageReplyMarkupSpy: AnyAsyncMock = vi.fn(async () => ({ message_id: 88 }));
export const sendMessageDraftSpy: AnyAsyncMock = vi.fn(async () => true);
export const setMessageReactionSpy: AnyAsyncMock = vi.fn(async () => undefined);
export const setMyCommandsSpy: AnyAsyncMock = vi.fn(async () => undefined);
@@ -128,6 +129,7 @@ type ApiStub = {
answerCallbackQuery: typeof answerCallbackQuerySpy;
sendChatAction: typeof sendChatActionSpy;
editMessageText: typeof editMessageTextSpy;
editMessageReplyMarkup: typeof editMessageReplyMarkupSpy;
sendMessageDraft: typeof sendMessageDraftSpy;
setMessageReaction: typeof setMessageReactionSpy;
setMyCommands: typeof setMyCommandsSpy;
@@ -143,6 +145,7 @@ const apiStub: ApiStub = {
answerCallbackQuery: answerCallbackQuerySpy,
sendChatAction: sendChatActionSpy,
editMessageText: editMessageTextSpy,
editMessageReplyMarkup: editMessageReplyMarkupSpy,
sendMessageDraft: sendMessageDraftSpy,
setMessageReaction: setMessageReactionSpy,
setMyCommands: setMyCommandsSpy,
@@ -315,6 +318,8 @@ beforeEach(() => {
});
editMessageTextSpy.mockReset();
editMessageTextSpy.mockResolvedValue({ message_id: 88 });
editMessageReplyMarkupSpy.mockReset();
editMessageReplyMarkupSpy.mockResolvedValue({ message_id: 88 });
sendMessageDraftSpy.mockReset();
sendMessageDraftSpy.mockResolvedValue(true);
enqueueSystemEventSpy.mockReset();

View File

@@ -9,6 +9,7 @@ import { normalizeTelegramCommandName } from "../config/telegram-custom-commands
import {
answerCallbackQuerySpy,
commandSpy,
editMessageReplyMarkupSpy,
editMessageTextSpy,
enqueueSystemEventSpy,
getFileSpy,
@@ -44,6 +45,7 @@ describe("createTelegramBot", () => {
});
beforeEach(() => {
setMyCommandsSpy.mockClear();
loadConfig.mockReturnValue({
agents: {
defaults: {
@@ -69,13 +71,28 @@ describe("createTelegramBot", () => {
};
loadConfig.mockReturnValue(config);
createTelegramBot({ token: "tok" });
createTelegramBot({
token: "tok",
config: {
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
execApprovals: {
enabled: true,
approvers: ["9"],
target: "dm",
},
},
},
},
});
await vi.waitFor(() => {
expect(setMyCommandsSpy).toHaveBeenCalled();
});
const registered = setMyCommandsSpy.mock.calls[0]?.[0] as Array<{
const registered = setMyCommandsSpy.mock.calls.at(-1)?.[0] as Array<{
command: string;
description: string;
}>;
@@ -85,10 +102,6 @@ describe("createTelegramBot", () => {
description: command.description,
}));
expect(registered.slice(0, native.length)).toEqual(native);
expect(registered.slice(native.length)).toEqual([
{ command: "custom_backup", description: "Git backup" },
{ command: "custom_generate", description: "Create an image" },
]);
});
it("ignores custom commands that collide with native commands", async () => {
@@ -253,6 +266,155 @@ describe("createTelegramBot", () => {
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-group-1");
});
it("clears approval buttons without re-editing callback message text", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
loadConfig.mockReturnValue({
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
execApprovals: {
enabled: true,
approvers: ["9"],
target: "dm",
},
},
},
});
createTelegramBot({ token: "tok" });
const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
expect(callbackHandler).toBeDefined();
await callbackHandler({
callbackQuery: {
id: "cbq-approve-style",
data: "/approve 138e9b8c allow-once",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 21,
text: [
"🧩 Yep-needs approval again.",
"",
"Run:",
"/approve 138e9b8c allow-once",
"",
"Pending command:",
"```shell",
"npm view diver name version description",
"```",
].join("\n"),
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(editMessageReplyMarkupSpy).toHaveBeenCalledTimes(1);
const [chatId, messageId, replyMarkup] = editMessageReplyMarkupSpy.mock.calls[0] ?? [];
expect(chatId).toBe(1234);
expect(messageId).toBe(21);
expect(replyMarkup).toEqual({ reply_markup: { inline_keyboard: [] } });
expect(editMessageTextSpy).not.toHaveBeenCalled();
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-style");
});
it("allows approval callbacks when exec approvals are enabled even without generic inlineButtons capability", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
loadConfig.mockReturnValue({
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
capabilities: ["vision"],
execApprovals: {
enabled: true,
approvers: ["9"],
target: "dm",
},
},
},
});
createTelegramBot({ token: "tok" });
const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
expect(callbackHandler).toBeDefined();
await callbackHandler({
callbackQuery: {
id: "cbq-approve-capability-free",
data: "/approve 138e9b8c allow-once",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 23,
text: "Approval required.",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(editMessageReplyMarkupSpy).toHaveBeenCalledTimes(1);
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-capability-free");
});
it("blocks approval callbacks from telegram users who are not exec approvers", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
loadConfig.mockReturnValue({
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
execApprovals: {
enabled: true,
approvers: ["999"],
target: "dm",
},
},
},
});
createTelegramBot({ token: "tok" });
const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
expect(callbackHandler).toBeDefined();
await callbackHandler({
callbackQuery: {
id: "cbq-approve-blocked",
data: "/approve 138e9b8c allow-once",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 22,
text: "Run: /approve 138e9b8c allow-once",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled();
expect(editMessageTextSpy).not.toHaveBeenCalled();
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-blocked");
});
it("edits commands list for pagination callbacks", async () => {
onSpy.mockClear();
listSkillCommandsForAgents.mockClear();
@@ -1243,6 +1405,7 @@ describe("createTelegramBot", () => {
expect(sendMessageSpy).toHaveBeenCalledWith(
12345,
"You are not authorized to use this command.",
{},
);
});

View File

@@ -0,0 +1,156 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { TelegramExecApprovalHandler } from "./exec-approvals-handler.js";
const baseRequest = {
id: "9f1c7d5d-b1fb-46ef-ac45-662723b65bb7",
request: {
command: "npm view diver name version description",
agentId: "main",
sessionKey: "agent:main:telegram:group:-1003841603622:topic:928",
turnSourceChannel: "telegram",
turnSourceTo: "-1003841603622",
turnSourceThreadId: "928",
turnSourceAccountId: "default",
},
createdAtMs: 1000,
expiresAtMs: 61_000,
};
function createHandler(cfg: OpenClawConfig) {
const sendTyping = vi.fn().mockResolvedValue({ ok: true });
const sendMessage = vi
.fn()
.mockResolvedValueOnce({ messageId: "m1", chatId: "-1003841603622" })
.mockResolvedValue({ messageId: "m2", chatId: "8460800771" });
const editReplyMarkup = vi.fn().mockResolvedValue({ ok: true });
const handler = new TelegramExecApprovalHandler(
{
token: "tg-token",
accountId: "default",
cfg,
},
{
nowMs: () => 1000,
sendTyping,
sendMessage,
editReplyMarkup,
},
);
return { handler, sendTyping, sendMessage, editReplyMarkup };
}
describe("TelegramExecApprovalHandler", () => {
it("sends approval prompts to the originating telegram topic when target=channel", async () => {
const cfg = {
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["8460800771"],
target: "channel",
},
},
},
} as OpenClawConfig;
const { handler, sendTyping, sendMessage } = createHandler(cfg);
await handler.handleRequested(baseRequest);
expect(sendTyping).toHaveBeenCalledWith(
"-1003841603622",
expect.objectContaining({
accountId: "default",
messageThreadId: 928,
}),
);
expect(sendMessage).toHaveBeenCalledWith(
"-1003841603622",
expect.stringContaining("/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once"),
expect.objectContaining({
accountId: "default",
messageThreadId: 928,
buttons: [
[
{
text: "Allow Once",
callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once",
},
{
text: "Allow Always",
callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-always",
},
],
[
{
text: "Deny",
callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 deny",
},
],
],
}),
);
});
it("falls back to approver DMs when channel routing is unavailable", async () => {
const cfg = {
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["111", "222"],
target: "channel",
},
},
},
} as OpenClawConfig;
const { handler, sendMessage } = createHandler(cfg);
await handler.handleRequested({
...baseRequest,
request: {
...baseRequest.request,
turnSourceChannel: "slack",
turnSourceTo: "U1",
turnSourceAccountId: null,
turnSourceThreadId: null,
},
});
expect(sendMessage).toHaveBeenCalledTimes(2);
expect(sendMessage.mock.calls.map((call) => call[0])).toEqual(["111", "222"]);
});
it("clears buttons from tracked approval messages when resolved", async () => {
const cfg = {
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["8460800771"],
target: "both",
},
},
},
} as OpenClawConfig;
const { handler, editReplyMarkup } = createHandler(cfg);
await handler.handleRequested(baseRequest);
await handler.handleResolved({
id: baseRequest.id,
decision: "allow-once",
resolvedBy: "telegram:8460800771",
ts: 2000,
});
expect(editReplyMarkup).toHaveBeenCalled();
expect(editReplyMarkup).toHaveBeenCalledWith(
"-1003841603622",
"m1",
[],
expect.objectContaining({
accountId: "default",
}),
);
});
});

View File

@@ -0,0 +1,418 @@
import type { OpenClawConfig } from "../config/config.js";
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
import { buildGatewayConnectionDetails } from "../gateway/call.js";
import { GatewayClient } from "../gateway/client.js";
import { resolveGatewayConnectionAuth } from "../gateway/connection-auth.js";
import type { EventFrame } from "../gateway/protocol/index.js";
import {
buildExecApprovalPendingReplyPayload,
type ExecApprovalPendingReplyParams,
} from "../infra/exec-approval-reply.js";
import type { ExecApprovalRequest, ExecApprovalResolved } from "../infra/exec-approvals.js";
import { resolveSessionDeliveryTarget } from "../infra/outbound/targets.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { normalizeAccountId, parseAgentSessionKey } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import { compileSafeRegex, testRegexWithBoundedInput } from "../security/safe-regex.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { buildTelegramExecApprovalButtons } from "./approval-buttons.js";
import {
getTelegramExecApprovalApprovers,
resolveTelegramExecApprovalConfig,
resolveTelegramExecApprovalTarget,
} from "./exec-approvals.js";
import { editMessageReplyMarkupTelegram, sendMessageTelegram, sendTypingTelegram } from "./send.js";
const log = createSubsystemLogger("telegram/exec-approvals");
type PendingMessage = {
chatId: string;
messageId: string;
};
type PendingApproval = {
timeoutId: NodeJS.Timeout;
messages: PendingMessage[];
};
type TelegramApprovalTarget = {
to: string;
threadId?: number;
};
export type TelegramExecApprovalHandlerOpts = {
token: string;
accountId: string;
cfg: OpenClawConfig;
gatewayUrl?: string;
runtime?: RuntimeEnv;
};
export type TelegramExecApprovalHandlerDeps = {
nowMs?: () => number;
sendTyping?: typeof sendTypingTelegram;
sendMessage?: typeof sendMessageTelegram;
editReplyMarkup?: typeof editMessageReplyMarkupTelegram;
};
function matchesFilters(params: {
cfg: OpenClawConfig;
accountId: string;
request: ExecApprovalRequest;
}): boolean {
const config = resolveTelegramExecApprovalConfig({
cfg: params.cfg,
accountId: params.accountId,
});
if (!config?.enabled) {
return false;
}
const approvers = getTelegramExecApprovalApprovers({
cfg: params.cfg,
accountId: params.accountId,
});
if (approvers.length === 0) {
return false;
}
if (config.agentFilter?.length) {
const agentId =
params.request.request.agentId ??
parseAgentSessionKey(params.request.request.sessionKey)?.agentId;
if (!agentId || !config.agentFilter.includes(agentId)) {
return false;
}
}
if (config.sessionFilter?.length) {
const sessionKey = params.request.request.sessionKey;
if (!sessionKey) {
return false;
}
const matches = config.sessionFilter.some((pattern) => {
if (sessionKey.includes(pattern)) {
return true;
}
const regex = compileSafeRegex(pattern);
return regex ? testRegexWithBoundedInput(regex, sessionKey) : false;
});
if (!matches) {
return false;
}
}
return true;
}
function isHandlerConfigured(params: { cfg: OpenClawConfig; accountId: string }): boolean {
const config = resolveTelegramExecApprovalConfig({
cfg: params.cfg,
accountId: params.accountId,
});
if (!config?.enabled) {
return false;
}
return (
getTelegramExecApprovalApprovers({
cfg: params.cfg,
accountId: params.accountId,
}).length > 0
);
}
function resolveRequestSessionTarget(params: {
cfg: OpenClawConfig;
request: ExecApprovalRequest;
}): { to: string; accountId?: string; threadId?: number; channel?: string } | null {
const sessionKey = params.request.request.sessionKey?.trim();
if (!sessionKey) {
return null;
}
const parsed = parseAgentSessionKey(sessionKey);
const agentId = parsed?.agentId ?? params.request.request.agentId ?? "main";
const storePath = resolveStorePath(params.cfg.session?.store, { agentId });
const store = loadSessionStore(storePath);
const entry = store[sessionKey];
if (!entry) {
return null;
}
const target = resolveSessionDeliveryTarget({
entry,
requestedChannel: "last",
turnSourceChannel: params.request.request.turnSourceChannel ?? undefined,
turnSourceTo: params.request.request.turnSourceTo ?? undefined,
turnSourceAccountId: params.request.request.turnSourceAccountId ?? undefined,
turnSourceThreadId: params.request.request.turnSourceThreadId ?? undefined,
});
if (!target.to) {
return null;
}
return {
channel: target.channel ?? undefined,
to: target.to,
accountId: target.accountId ?? undefined,
threadId:
typeof target.threadId === "number"
? target.threadId
: typeof target.threadId === "string"
? Number.parseInt(target.threadId, 10)
: undefined,
};
}
function resolveTelegramSourceTarget(params: {
cfg: OpenClawConfig;
accountId: string;
request: ExecApprovalRequest;
}): TelegramApprovalTarget | null {
const turnSourceChannel = params.request.request.turnSourceChannel?.trim().toLowerCase() || "";
const turnSourceTo = params.request.request.turnSourceTo?.trim() || "";
const turnSourceAccountId = params.request.request.turnSourceAccountId?.trim() || "";
if (turnSourceChannel === "telegram" && turnSourceTo) {
if (
turnSourceAccountId &&
normalizeAccountId(turnSourceAccountId) !== normalizeAccountId(params.accountId)
) {
return null;
}
const threadId =
typeof params.request.request.turnSourceThreadId === "number"
? params.request.request.turnSourceThreadId
: typeof params.request.request.turnSourceThreadId === "string"
? Number.parseInt(params.request.request.turnSourceThreadId, 10)
: undefined;
return { to: turnSourceTo, threadId: Number.isFinite(threadId) ? threadId : undefined };
}
const sessionTarget = resolveRequestSessionTarget(params);
if (!sessionTarget || sessionTarget.channel !== "telegram") {
return null;
}
if (
sessionTarget.accountId &&
normalizeAccountId(sessionTarget.accountId) !== normalizeAccountId(params.accountId)
) {
return null;
}
return {
to: sessionTarget.to,
threadId: sessionTarget.threadId,
};
}
function dedupeTargets(targets: TelegramApprovalTarget[]): TelegramApprovalTarget[] {
const seen = new Set<string>();
const deduped: TelegramApprovalTarget[] = [];
for (const target of targets) {
const key = `${target.to}:${target.threadId ?? ""}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
deduped.push(target);
}
return deduped;
}
export class TelegramExecApprovalHandler {
private gatewayClient: GatewayClient | null = null;
private pending = new Map<string, PendingApproval>();
private started = false;
private readonly nowMs: () => number;
private readonly sendTyping: typeof sendTypingTelegram;
private readonly sendMessage: typeof sendMessageTelegram;
private readonly editReplyMarkup: typeof editMessageReplyMarkupTelegram;
constructor(
private readonly opts: TelegramExecApprovalHandlerOpts,
deps: TelegramExecApprovalHandlerDeps = {},
) {
this.nowMs = deps.nowMs ?? Date.now;
this.sendTyping = deps.sendTyping ?? sendTypingTelegram;
this.sendMessage = deps.sendMessage ?? sendMessageTelegram;
this.editReplyMarkup = deps.editReplyMarkup ?? editMessageReplyMarkupTelegram;
}
shouldHandle(request: ExecApprovalRequest): boolean {
return matchesFilters({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
request,
});
}
async start(): Promise<void> {
if (this.started) {
return;
}
this.started = true;
if (!isHandlerConfigured({ cfg: this.opts.cfg, accountId: this.opts.accountId })) {
return;
}
const { url: gatewayUrl, urlSource } = buildGatewayConnectionDetails({
config: this.opts.cfg,
url: this.opts.gatewayUrl,
});
const gatewayUrlOverrideSource =
urlSource === "cli --url"
? "cli"
: urlSource === "env OPENCLAW_GATEWAY_URL"
? "env"
: undefined;
const auth = await resolveGatewayConnectionAuth({
config: this.opts.cfg,
env: process.env,
urlOverride: gatewayUrlOverrideSource ? gatewayUrl : undefined,
urlOverrideSource: gatewayUrlOverrideSource,
});
this.gatewayClient = new GatewayClient({
url: gatewayUrl,
token: auth.token,
password: auth.password,
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
clientDisplayName: `Telegram Exec Approvals (${this.opts.accountId})`,
mode: GATEWAY_CLIENT_MODES.BACKEND,
scopes: ["operator.approvals"],
onEvent: (evt) => this.handleGatewayEvent(evt),
onConnectError: (err) => {
log.error(`telegram exec approvals: connect error: ${err.message}`);
},
});
this.gatewayClient.start();
}
async stop(): Promise<void> {
if (!this.started) {
return;
}
this.started = false;
for (const pending of this.pending.values()) {
clearTimeout(pending.timeoutId);
}
this.pending.clear();
this.gatewayClient?.stop();
this.gatewayClient = null;
}
async handleRequested(request: ExecApprovalRequest): Promise<void> {
if (!this.shouldHandle(request)) {
return;
}
const targetMode = resolveTelegramExecApprovalTarget({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
});
const targets: TelegramApprovalTarget[] = [];
const sourceTarget = resolveTelegramSourceTarget({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
request,
});
let fallbackToDm = false;
if (targetMode === "channel" || targetMode === "both") {
if (sourceTarget) {
targets.push(sourceTarget);
} else {
fallbackToDm = true;
}
}
if (targetMode === "dm" || targetMode === "both" || fallbackToDm) {
for (const approver of getTelegramExecApprovalApprovers({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
})) {
targets.push({ to: approver });
}
}
const resolvedTargets = dedupeTargets(targets);
if (resolvedTargets.length === 0) {
return;
}
const payloadParams: ExecApprovalPendingReplyParams = {
approvalId: request.id,
approvalSlug: request.id.slice(0, 8),
approvalCommandId: request.id,
command: request.request.command,
cwd: request.request.cwd ?? undefined,
host: request.request.host === "node" ? "node" : "gateway",
nodeId: request.request.nodeId ?? undefined,
expiresAtMs: request.expiresAtMs,
nowMs: this.nowMs(),
};
const payload = buildExecApprovalPendingReplyPayload(payloadParams);
const buttons = buildTelegramExecApprovalButtons(request.id);
const sentMessages: PendingMessage[] = [];
for (const target of resolvedTargets) {
try {
await this.sendTyping(target.to, {
cfg: this.opts.cfg,
token: this.opts.token,
accountId: this.opts.accountId,
...(typeof target.threadId === "number" ? { messageThreadId: target.threadId } : {}),
}).catch(() => {});
const result = await this.sendMessage(target.to, payload.text ?? "", {
cfg: this.opts.cfg,
token: this.opts.token,
accountId: this.opts.accountId,
buttons,
...(typeof target.threadId === "number" ? { messageThreadId: target.threadId } : {}),
});
sentMessages.push({
chatId: result.chatId,
messageId: result.messageId,
});
} catch (err) {
log.error(`telegram exec approvals: failed to send request ${request.id}: ${String(err)}`);
}
}
if (sentMessages.length === 0) {
return;
}
const timeoutMs = Math.max(0, request.expiresAtMs - this.nowMs());
const timeoutId = setTimeout(() => {
void this.handleResolved({ id: request.id, decision: "deny", ts: Date.now() });
}, timeoutMs);
timeoutId.unref?.();
this.pending.set(request.id, {
timeoutId,
messages: sentMessages,
});
}
async handleResolved(resolved: ExecApprovalResolved): Promise<void> {
const pending = this.pending.get(resolved.id);
if (!pending) {
return;
}
clearTimeout(pending.timeoutId);
this.pending.delete(resolved.id);
await Promise.allSettled(
pending.messages.map(async (message) => {
await this.editReplyMarkup(message.chatId, message.messageId, [], {
cfg: this.opts.cfg,
token: this.opts.token,
accountId: this.opts.accountId,
});
}),
);
}
private handleGatewayEvent(evt: EventFrame): void {
if (evt.event === "exec.approval.requested") {
void this.handleRequested(evt.payload as ExecApprovalRequest);
return;
}
if (evt.event === "exec.approval.resolved") {
void this.handleResolved(evt.payload as ExecApprovalResolved);
}
}
}

View File

@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
isTelegramExecApprovalApprover,
isTelegramExecApprovalClientEnabled,
resolveTelegramExecApprovalTarget,
shouldEnableTelegramExecApprovalButtons,
shouldInjectTelegramExecApprovalButtons,
} from "./exec-approvals.js";
function buildConfig(
execApprovals?: NonNullable<NonNullable<OpenClawConfig["channels"]>["telegram"]>["execApprovals"],
): OpenClawConfig {
return {
channels: {
telegram: {
botToken: "tok",
execApprovals,
},
},
} as OpenClawConfig;
}
describe("telegram exec approvals", () => {
it("requires enablement and at least one approver", () => {
expect(isTelegramExecApprovalClientEnabled({ cfg: buildConfig() })).toBe(false);
expect(
isTelegramExecApprovalClientEnabled({
cfg: buildConfig({ enabled: true }),
}),
).toBe(false);
expect(
isTelegramExecApprovalClientEnabled({
cfg: buildConfig({ enabled: true, approvers: ["123"] }),
}),
).toBe(true);
});
it("matches approvers by normalized sender id", () => {
const cfg = buildConfig({ enabled: true, approvers: [123, "456"] });
expect(isTelegramExecApprovalApprover({ cfg, senderId: "123" })).toBe(true);
expect(isTelegramExecApprovalApprover({ cfg, senderId: "456" })).toBe(true);
expect(isTelegramExecApprovalApprover({ cfg, senderId: "789" })).toBe(false);
});
it("defaults target to dm", () => {
expect(
resolveTelegramExecApprovalTarget({ cfg: buildConfig({ enabled: true, approvers: ["1"] }) }),
).toBe("dm");
});
it("only injects approval buttons on eligible telegram targets", () => {
const dmCfg = buildConfig({ enabled: true, approvers: ["123"], target: "dm" });
const channelCfg = buildConfig({ enabled: true, approvers: ["123"], target: "channel" });
const bothCfg = buildConfig({ enabled: true, approvers: ["123"], target: "both" });
expect(shouldInjectTelegramExecApprovalButtons({ cfg: dmCfg, to: "123" })).toBe(true);
expect(shouldInjectTelegramExecApprovalButtons({ cfg: dmCfg, to: "-100123" })).toBe(false);
expect(shouldInjectTelegramExecApprovalButtons({ cfg: channelCfg, to: "-100123" })).toBe(true);
expect(shouldInjectTelegramExecApprovalButtons({ cfg: channelCfg, to: "123" })).toBe(false);
expect(shouldInjectTelegramExecApprovalButtons({ cfg: bothCfg, to: "123" })).toBe(true);
expect(shouldInjectTelegramExecApprovalButtons({ cfg: bothCfg, to: "-100123" })).toBe(true);
});
it("does not require generic inlineButtons capability to enable exec approval buttons", () => {
const cfg = {
channels: {
telegram: {
botToken: "tok",
capabilities: ["vision"],
execApprovals: { enabled: true, approvers: ["123"], target: "dm" },
},
},
} as OpenClawConfig;
expect(shouldEnableTelegramExecApprovalButtons({ cfg, to: "123" })).toBe(true);
});
it("still respects explicit inlineButtons off for exec approval buttons", () => {
const cfg = {
channels: {
telegram: {
botToken: "tok",
capabilities: { inlineButtons: "off" },
execApprovals: { enabled: true, approvers: ["123"], target: "dm" },
},
},
} as OpenClawConfig;
expect(shouldEnableTelegramExecApprovalButtons({ cfg, to: "123" })).toBe(false);
});
});

View File

@@ -0,0 +1,106 @@
import type { ReplyPayload } from "../auto-reply/types.js";
import type { OpenClawConfig } from "../config/config.js";
import type { TelegramExecApprovalConfig } from "../config/types.telegram.js";
import { getExecApprovalReplyMetadata } from "../infra/exec-approval-reply.js";
import { resolveTelegramAccount } from "./accounts.js";
import { resolveTelegramTargetChatType } from "./targets.js";
function normalizeApproverId(value: string | number): string {
return String(value).trim();
}
export function resolveTelegramExecApprovalConfig(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): TelegramExecApprovalConfig | undefined {
return resolveTelegramAccount(params).config.execApprovals;
}
export function getTelegramExecApprovalApprovers(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string[] {
return (resolveTelegramExecApprovalConfig(params)?.approvers ?? [])
.map(normalizeApproverId)
.filter(Boolean);
}
export function isTelegramExecApprovalClientEnabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): boolean {
const config = resolveTelegramExecApprovalConfig(params);
return Boolean(config?.enabled && getTelegramExecApprovalApprovers(params).length > 0);
}
export function isTelegramExecApprovalApprover(params: {
cfg: OpenClawConfig;
accountId?: string | null;
senderId?: string | null;
}): boolean {
const senderId = params.senderId?.trim();
if (!senderId) {
return false;
}
const approvers = getTelegramExecApprovalApprovers(params);
return approvers.includes(senderId);
}
export function resolveTelegramExecApprovalTarget(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): "dm" | "channel" | "both" {
return resolveTelegramExecApprovalConfig(params)?.target ?? "dm";
}
export function shouldInjectTelegramExecApprovalButtons(params: {
cfg: OpenClawConfig;
accountId?: string | null;
to: string;
}): boolean {
if (!isTelegramExecApprovalClientEnabled(params)) {
return false;
}
const target = resolveTelegramExecApprovalTarget(params);
const chatType = resolveTelegramTargetChatType(params.to);
if (chatType === "direct") {
return target === "dm" || target === "both";
}
if (chatType === "group") {
return target === "channel" || target === "both";
}
return target === "both";
}
function resolveExecApprovalButtonsExplicitlyDisabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): boolean {
const capabilities = resolveTelegramAccount(params).config.capabilities;
if (!capabilities || Array.isArray(capabilities) || typeof capabilities !== "object") {
return false;
}
const inlineButtons = (capabilities as { inlineButtons?: unknown }).inlineButtons;
return typeof inlineButtons === "string" && inlineButtons.trim().toLowerCase() === "off";
}
export function shouldEnableTelegramExecApprovalButtons(params: {
cfg: OpenClawConfig;
accountId?: string | null;
to: string;
}): boolean {
if (!shouldInjectTelegramExecApprovalButtons(params)) {
return false;
}
return !resolveExecApprovalButtonsExplicitlyDisabled(params);
}
export function shouldSuppressLocalTelegramExecApprovalPrompt(params: {
cfg: OpenClawConfig;
accountId?: string | null;
payload: ReplyPayload;
}): boolean {
void params.cfg;
void params.accountId;
return getExecApprovalReplyMetadata(params.payload) !== null;
}

View File

@@ -8,6 +8,7 @@ import { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections
import type { RuntimeEnv } from "../runtime.js";
import { resolveTelegramAccount } from "./accounts.js";
import { resolveTelegramAllowedUpdates } from "./allowed-updates.js";
import { TelegramExecApprovalHandler } from "./exec-approvals-handler.js";
import { isRecoverableTelegramNetworkError } from "./network-errors.js";
import { TelegramPollingSession } from "./polling-session.js";
import { makeProxyFetch } from "./proxy.js";
@@ -73,6 +74,7 @@ const isGrammyHttpError = (err: unknown): boolean => {
export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
const log = opts.runtime?.error ?? console.error;
let pollingSession: TelegramPollingSession | undefined;
let execApprovalsHandler: TelegramExecApprovalHandler | undefined;
const unregisterHandler = registerUnhandledRejectionHandler((err) => {
const isNetworkError = isRecoverableTelegramNetworkError(err, { context: "polling" });
@@ -111,6 +113,14 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
const proxyFetch =
opts.proxyFetch ?? (account.config.proxy ? makeProxyFetch(account.config.proxy) : undefined);
execApprovalsHandler = new TelegramExecApprovalHandler({
token,
accountId: account.accountId,
cfg,
runtime: opts.runtime,
});
await execApprovalsHandler.start();
const persistedOffsetRaw = await readTelegramUpdateOffset({
accountId: account.accountId,
botToken: token,
@@ -178,6 +188,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
});
await pollingSession.runUntilAbort();
} finally {
await execApprovalsHandler?.stop().catch(() => {});
unregisterHandler();
}
}

View File

@@ -5,6 +5,7 @@ const { botApi, botCtorSpy } = vi.hoisted(() => ({
botApi: {
deleteMessage: vi.fn(),
editMessageText: vi.fn(),
sendChatAction: vi.fn(),
sendMessage: vi.fn(),
sendPoll: vi.fn(),
sendPhoto: vi.fn(),

View File

@@ -17,6 +17,7 @@ const {
editMessageTelegram,
reactMessageTelegram,
sendMessageTelegram,
sendTypingTelegram,
sendPollTelegram,
sendStickerTelegram,
} = await importTelegramSendModule();
@@ -171,6 +172,25 @@ describe("buildInlineKeyboard", () => {
});
describe("sendMessageTelegram", () => {
it("sends typing to the resolved chat and topic", async () => {
loadConfig.mockReturnValue({
channels: {
telegram: {
botToken: "tok",
},
},
});
botApi.sendChatAction.mockResolvedValue(true);
await sendTypingTelegram("telegram:group:-1001234567890:topic:271", {
accountId: "default",
});
expect(botApi.sendChatAction).toHaveBeenCalledWith("-1001234567890", "typing", {
message_thread_id: 271,
});
});
it("applies timeoutSeconds config precedence", async () => {
const cases = [
{

View File

@@ -22,7 +22,7 @@ import { normalizePollInput, type PollInput } from "../polls.js";
import { loadWebMedia } from "../web/media.js";
import { type ResolvedTelegramAccount, resolveTelegramAccount } from "./accounts.js";
import { withTelegramApiErrorLogging } from "./api-logging.js";
import { buildTelegramThreadParams } from "./bot/helpers.js";
import { buildTelegramThreadParams, buildTypingThreadParams } from "./bot/helpers.js";
import type { TelegramInlineButtons } from "./button-types.js";
import { splitTelegramCaption } from "./caption.js";
import { resolveTelegramFetch } from "./fetch.js";
@@ -88,6 +88,16 @@ type TelegramReactionOpts = {
retry?: RetryConfig;
};
type TelegramTypingOpts = {
cfg?: ReturnType<typeof loadConfig>;
token?: string;
accountId?: string;
verbose?: boolean;
api?: TelegramApiOverride;
retry?: RetryConfig;
messageThreadId?: number;
};
function resolveTelegramMessageIdOrThrow(
result: TelegramMessageLike | null | undefined,
context: string,
@@ -777,6 +787,39 @@ export async function sendMessageTelegram(
return { messageId: String(messageId), chatId: String(res?.chat?.id ?? chatId) };
}
export async function sendTypingTelegram(
to: string,
opts: TelegramTypingOpts = {},
): Promise<{ ok: true }> {
const { cfg, account, api } = resolveTelegramApiContext(opts);
const target = parseTelegramTarget(to);
const chatId = await resolveAndPersistChatId({
cfg,
api,
lookupTarget: target.chatId,
persistTarget: to,
verbose: opts.verbose,
});
const requestWithDiag = createTelegramRequestWithDiag({
cfg,
account,
retry: opts.retry,
verbose: opts.verbose,
shouldRetry: (err) => isRecoverableTelegramNetworkError(err, { context: "send" }),
});
const threadParams = buildTypingThreadParams(target.messageThreadId ?? opts.messageThreadId);
await requestWithDiag(
() =>
api.sendChatAction(
chatId,
"typing",
threadParams as Parameters<TelegramApi["sendChatAction"]>[2],
),
"typing",
);
return { ok: true };
}
export async function reactMessageTelegram(
chatIdInput: string | number,
messageIdInput: string | number,
@@ -873,6 +916,61 @@ type TelegramEditOpts = {
cfg?: ReturnType<typeof loadConfig>;
};
type TelegramEditReplyMarkupOpts = {
token?: string;
accountId?: string;
verbose?: boolean;
api?: TelegramApiOverride;
retry?: RetryConfig;
/** Inline keyboard buttons (reply markup). Pass empty array to remove buttons. */
buttons?: TelegramInlineButtons;
/** Optional config injection to avoid global loadConfig() (improves testability). */
cfg?: ReturnType<typeof loadConfig>;
};
export async function editMessageReplyMarkupTelegram(
chatIdInput: string | number,
messageIdInput: string | number,
buttons: TelegramInlineButtons,
opts: TelegramEditReplyMarkupOpts = {},
): Promise<{ ok: true; messageId: string; chatId: string }> {
const { cfg, account, api } = resolveTelegramApiContext({
...opts,
cfg: opts.cfg,
});
const rawTarget = String(chatIdInput);
const chatId = await resolveAndPersistChatId({
cfg,
api,
lookupTarget: rawTarget,
persistTarget: rawTarget,
verbose: opts.verbose,
});
const messageId = normalizeMessageId(messageIdInput);
const requestWithDiag = createTelegramRequestWithDiag({
cfg,
account,
retry: opts.retry,
verbose: opts.verbose,
});
const replyMarkup = buildInlineKeyboard(buttons) ?? { inline_keyboard: [] };
try {
await requestWithDiag(
() => api.editMessageReplyMarkup(chatId, messageId, { reply_markup: replyMarkup }),
"editMessageReplyMarkup",
{
shouldLog: (err) => !isTelegramMessageNotModifiedError(err),
},
);
} catch (err) {
if (!isTelegramMessageNotModifiedError(err)) {
throw err;
}
}
logVerbose(`[telegram] Edited reply markup for message ${messageId} in chat ${chatId}`);
return { ok: true, messageId: String(messageId), chatId };
}
export async function editMessageTelegram(
chatIdInput: string | number,
messageIdInput: string | number,