Telegram: exec approvals for OpenCode/Codex (#37233)
Merged via squash. Prepared head SHA: f2433790941841ade0efe6292ff4909b2edd6f18 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:
@@ -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
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
61
src/agents/bash-tools.exec-approval-followup.ts
Normal file
61
src/agents/bash-tools.exec-approval-followup.ts
Normal 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;
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -54,6 +54,7 @@ export type EmbeddedRunAttemptResult = {
|
||||
actionFingerprint?: string;
|
||||
};
|
||||
didSendViaMessagingTool: boolean;
|
||||
didSendDeterministicApprovalPrompt?: boolean;
|
||||
messagingToolSentTexts: string[];
|
||||
messagingToolSentMediaUrls: string[];
|
||||
messagingToolSentTargets: MessagingToolSend[];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -28,6 +28,7 @@ function createMockContext(overrides?: {
|
||||
messagingToolSentTextsNormalized: [],
|
||||
messagingToolSentMediaUrls: [],
|
||||
messagingToolSentTargets: [],
|
||||
deterministicApprovalPromptSent: false,
|
||||
},
|
||||
log: { debug: vi.fn(), warn: vi.fn() },
|
||||
shouldEmitToolResult: vi.fn(() => false),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -10,6 +10,7 @@ export function createBaseToolHandlerState() {
|
||||
messagingToolSentTextsNormalized: [] as string[],
|
||||
messagingToolSentMediaUrls: [] as string[],
|
||||
messagingToolSentTargets: [] as unknown[],
|
||||
deterministicApprovalPromptSent: false,
|
||||
blockBuffer: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -445,8 +445,8 @@ export async function runAgentTurnWithFallback(params: {
|
||||
}
|
||||
await params.typingSignals.signalTextDelta(text);
|
||||
await onToolResult({
|
||||
...payload,
|
||||
text,
|
||||
mediaUrls: payload.mediaUrls,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -115,7 +115,6 @@ export const telegramOutbound: ChannelOutboundAdapter = {
|
||||
quoteText,
|
||||
mediaLocalRoots,
|
||||
};
|
||||
|
||||
if (mediaUrls.length === 0) {
|
||||
const result = await send(to, text, {
|
||||
...payloadOpts,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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"). */
|
||||
|
||||
@@ -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,
|
||||
|
||||
23
src/discord/exec-approvals.ts
Normal file
23
src/discord/exec-approvals.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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() : "";
|
||||
|
||||
@@ -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````");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
172
src/infra/exec-approval-reply.ts
Normal file
172
src/infra/exec-approval-reply.ts
Normal 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"),
|
||||
};
|
||||
}
|
||||
77
src/infra/exec-approval-surface.ts
Normal file
77
src/infra/exec-approval-surface.ts
Normal 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;
|
||||
}
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -355,6 +355,7 @@ async function sendExecFinishedEvent(
|
||||
timedOut: params.result.timedOut,
|
||||
success: params.result.success,
|
||||
output: combined,
|
||||
suppressNotifyOnExit: params.suppressNotifyOnExit,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
18
src/telegram/approval-buttons.test.ts
Normal file
18
src/telegram/approval-buttons.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
42
src/telegram/approval-buttons.ts
Normal file
42
src/telegram/approval-buttons.ts
Normal 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;
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.",
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
156
src/telegram/exec-approvals-handler.test.ts
Normal file
156
src/telegram/exec-approvals-handler.test.ts
Normal 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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
418
src/telegram/exec-approvals-handler.ts
Normal file
418
src/telegram/exec-approvals-handler.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
92
src/telegram/exec-approvals.test.ts
Normal file
92
src/telegram/exec-approvals.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
106
src/telegram/exec-approvals.ts
Normal file
106
src/telegram/exec-approvals.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user