From 17201747579c27669ef0009c069d7cc9f9de7df0 Mon Sep 17 00:00:00 2001 From: Shadow Date: Mon, 9 Mar 2026 21:30:47 -0500 Subject: [PATCH 1/7] fix: auto-close no-ci PR label and document triage labels --- .github/workflows/auto-response.yml | 1 + AGENTS.md | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml index a40149b7ccb..60e1707cf35 100644 --- a/.github/workflows/auto-response.yml +++ b/.github/workflows/auto-response.yml @@ -51,6 +51,7 @@ jobs: }, { label: "r: no-ci-pr", + close: true, message: "Please don't make PRs for test failures on main.\n\n" + "The team is aware of those and will handle them directly on the codebase, not only fixing the tests but also investigating what the root cause is. Having to sift through test-fix-PRs (including some that have been out of date for weeks...) on top of that doesn't help. There are already way too many PRs for humans to manage; please don't make the flood worse.\n\n" + diff --git a/AGENTS.md b/AGENTS.md index b70210cf8e3..1516f2e4f58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,23 @@ - GitHub searching footgun: don't limit yourself to the first 500 issues or PRs when wanting to search all. Unless you're supposed to look at the most recent, keep going until you've reached the last page in the search - Security advisory analysis: before triage/severity decisions, read `SECURITY.md` to align with OpenClaw's trust model and design boundaries. +## Auto-close labels (issues and PRs) + +- If an issue/PR matches one of the reasons below, apply the label and let `.github/workflows/auto-response.yml` handle comment/close/lock. +- Do not manually close + manually comment for these reasons. +- Why: keeps wording consistent, preserves automation behavior (`state_reason`, locking), and keeps triage/reporting searchable by label. +- `r:*` labels can be used on both issues and PRs. + +- `r: skill`: close with guidance to publish skills on Clawhub. +- `r: support`: close with redirect to Discord support + stuck FAQ. +- `r: no-ci-pr`: close test-fix-only PRs for failing `main` CI and post the standard explanation. +- `r: too-many-prs`: close when author exceeds active PR limit. +- `r: testflight`: close requests asking for TestFlight access/builds. OpenClaw does not provide TestFlight distribution yet, so use the standard response (“Not available, build from source.”) instead of ad-hoc replies. +- `r: third-party-extension`: close with guidance to ship as third-party plugin. +- `r: moltbook`: close + lock as off-topic (not affiliated). +- `invalid`: close invalid items (issues are closed as `not_planned`; PRs are closed). +- `dirty`: close PRs with too many unrelated/unexpected changes (PR-only label). + ## Project Structure & Module Organization - Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`). From 25c2facc2b93432a597b98da7db5a3ebdcb6ce2a Mon Sep 17 00:00:00 2001 From: Zhe Liu <770120041@qq.com> Date: Mon, 9 Mar 2026 22:39:57 -0400 Subject: [PATCH 2/7] fix(agents): fix Brave llm-context empty snippets (#41387) Merged via squash. Prepared head SHA: 1e6f1d9d51607a115e4bf912f53149a26a5cdd82 Co-authored-by: zheliu2 <15888718+zheliu2@users.noreply.github.com> Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com> Reviewed-by: @obviyus --- CHANGELOG.md | 1 + src/agents/tools/web-search.test.ts | 75 +++++++++++++++++++ src/agents/tools/web-search.ts | 24 +++--- .../tools/web-tools.enabled-defaults.test.ts | 2 +- 4 files changed, 92 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce8a07061ec..7b42bac2703 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai - Agents/fallback: treat HTTP 499 responses as transient in both raw-text and structured failover paths so Anthropic-style client-closed overload responses trigger model fallback reliably. (#41468) thanks @zeroasterisk. - 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. ## 2026.3.8 diff --git a/src/agents/tools/web-search.test.ts b/src/agents/tools/web-search.test.ts index 4a7b002d784..b8bccd7dfd3 100644 --- a/src/agents/tools/web-search.test.ts +++ b/src/agents/tools/web-search.test.ts @@ -23,6 +23,7 @@ const { resolveKimiBaseUrl, extractKimiCitations, resolveBraveMode, + mapBraveLlmContextResults, } = __testing; const kimiApiKeyEnv = ["KIMI_API", "KEY"].join("_"); @@ -393,3 +394,77 @@ describe("resolveBraveMode", () => { expect(resolveBraveMode({ mode: "invalid" })).toBe("web"); }); }); + +describe("mapBraveLlmContextResults", () => { + it("maps plain string snippets correctly", () => { + const results = mapBraveLlmContextResults({ + grounding: { + generic: [ + { + url: "https://example.com/page", + title: "Example Page", + snippets: ["first snippet", "second snippet"], + }, + ], + }, + }); + expect(results).toEqual([ + { + url: "https://example.com/page", + title: "Example Page", + snippets: ["first snippet", "second snippet"], + siteName: "example.com", + }, + ]); + }); + + it("filters out non-string and empty snippets", () => { + const results = mapBraveLlmContextResults({ + grounding: { + generic: [ + { + url: "https://example.com", + title: "Test", + snippets: ["valid", "", null, undefined, 42, { text: "object" }] as string[], + }, + ], + }, + }); + expect(results[0].snippets).toEqual(["valid"]); + }); + + it("handles missing snippets array", () => { + const results = mapBraveLlmContextResults({ + grounding: { + generic: [{ url: "https://example.com", title: "No Snippets" } as never], + }, + }); + expect(results[0].snippets).toEqual([]); + }); + + it("handles empty grounding.generic", () => { + expect(mapBraveLlmContextResults({ grounding: { generic: [] } })).toEqual([]); + }); + + it("handles missing grounding.generic", () => { + expect(mapBraveLlmContextResults({ grounding: {} } as never)).toEqual([]); + }); + + it("resolves siteName from URL hostname", () => { + const results = mapBraveLlmContextResults({ + grounding: { + generic: [{ url: "https://docs.example.org/path", title: "Docs", snippets: ["text"] }], + }, + }); + expect(results[0].siteName).toBe("docs.example.org"); + }); + + it("sets siteName to undefined for invalid URLs", () => { + const results = mapBraveLlmContextResults({ + grounding: { + generic: [{ url: "not-a-url", title: "Bad URL", snippets: ["text"] }], + }, + }); + expect(results[0].siteName).toBeUndefined(); + }); +}); diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 47c5a5abc94..d4f88caea61 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -272,8 +272,7 @@ type BraveSearchResponse = { }; }; -type BraveLlmContextSnippet = { text: string }; -type BraveLlmContextResult = { url: string; title: string; snippets: BraveLlmContextSnippet[] }; +type BraveLlmContextResult = { url: string; title: string; snippets: string[] }; type BraveLlmContextResponse = { grounding: { generic?: BraveLlmContextResult[] }; sources?: { url?: string; hostname?: string; date?: string }[]; @@ -1429,6 +1428,18 @@ async function runKimiSearch(params: { }; } +function mapBraveLlmContextResults( + data: BraveLlmContextResponse, +): { url: string; title: string; snippets: string[]; siteName?: string }[] { + const genericResults = Array.isArray(data.grounding?.generic) ? data.grounding.generic : []; + return genericResults.map((entry) => ({ + url: entry.url ?? "", + title: entry.title ?? "", + snippets: (entry.snippets ?? []).filter((s) => typeof s === "string" && s.length > 0), + siteName: resolveSiteName(entry.url) || undefined, + })); +} + async function runBraveLlmContextSearch(params: { query: string; apiKey: string; @@ -1477,13 +1488,7 @@ async function runBraveLlmContextSearch(params: { } const data = (await res.json()) as BraveLlmContextResponse; - const genericResults = Array.isArray(data.grounding?.generic) ? data.grounding.generic : []; - const mapped = genericResults.map((entry) => ({ - url: entry.url ?? "", - title: entry.title ?? "", - snippets: (entry.snippets ?? []).map((s) => s.text ?? "").filter(Boolean), - siteName: resolveSiteName(entry.url) || undefined, - })); + const mapped = mapBraveLlmContextResults(data); return { results: mapped, sources: data.sources }; }, @@ -2122,4 +2127,5 @@ export const __testing = { extractKimiCitations, resolveRedirectUrl: resolveCitationRedirectUrl, resolveBraveMode, + mapBraveLlmContextResults, } as const; diff --git a/src/agents/tools/web-tools.enabled-defaults.test.ts b/src/agents/tools/web-tools.enabled-defaults.test.ts index 54485908b8b..80dcd6a025d 100644 --- a/src/agents/tools/web-tools.enabled-defaults.test.ts +++ b/src/agents/tools/web-tools.enabled-defaults.test.ts @@ -694,7 +694,7 @@ describe("web_search external content wrapping", () => { const mockFetch = installBraveLlmContextFetch({ title: "Context title", url: "https://example.com/ctx", - snippets: [{ text: "Context chunk one" }, { text: "Context chunk two" }], + snippets: ["Context chunk one", "Context chunk two"], }); const tool = createWebSearchTool({ From 9432a8bb3f42f50ed7e9988388c1b120ed63a680 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 10 Mar 2026 08:14:25 +0530 Subject: [PATCH 3/7] test: allowlist detect-secrets fixture strings --- src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index f60a127a0ab..3500df63876 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -32,7 +32,7 @@ const OPENROUTER_CREDITS_MESSAGE = "Payment Required: insufficient credits"; // Issue-backed Anthropic/OpenAI-compatible insufficient_quota payload under HTTP 400: // https://github.com/openclaw/openclaw/issues/23440 const INSUFFICIENT_QUOTA_PAYLOAD = - '{"type":"error","error":{"type":"insufficient_quota","message":"Your account has insufficient quota balance to run this request."}}'; + '{"type":"error","error":{"type":"insufficient_quota","message":"Your account has insufficient quota balance to run this request."}}'; // pragma: allowlist secret // Together AI error code examples: https://docs.together.ai/docs/error-codes const TOGETHER_PAYMENT_REQUIRED_MESSAGE = "402 Payment Required: The account associated with this API key has reached its maximum allowed monthly spending limit."; @@ -42,7 +42,7 @@ const TOGETHER_ENGINE_OVERLOADED_MESSAGE = const GROQ_TOO_MANY_REQUESTS_MESSAGE = "429 Too Many Requests: Too many requests were sent in a given timeframe."; const GROQ_SERVICE_UNAVAILABLE_MESSAGE = - "503 Service Unavailable: The server is temporarily unable to handle the request due to overloading or maintenance."; + "503 Service Unavailable: The server is temporarily unable to handle the request due to overloading or maintenance."; // pragma: allowlist secret describe("isAuthPermanentErrorMessage", () => { it("matches permanent auth failure patterns", () => { From de49a8b72c12e89170f36143ac30aaa4e938aafc Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Mon, 9 Mar 2026 23:04:35 -0400 Subject: [PATCH 4/7] 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 --- CHANGELOG.md | 1 + docs/channels/telegram.md | 43 +- docs/tools/exec-approvals.md | 26 ++ extensions/telegram/src/channel.test.ts | 35 ++ extensions/telegram/src/channel.ts | 60 +++ .../bash-tools.exec-approval-followup.ts | 61 +++ src/agents/bash-tools.exec-host-gateway.ts | 146 ++++-- src/agents/bash-tools.exec-host-node.ts | 182 ++++++-- src/agents/bash-tools.exec-runtime.ts | 34 ++ src/agents/bash-tools.exec-types.ts | 15 + .../bash-tools.exec.approval-id.test.ts | 303 ++++++++++++- src/agents/pi-embedded-runner/run.ts | 3 + src/agents/pi-embedded-runner/run/attempt.ts | 2 + src/agents/pi-embedded-runner/run/params.ts | 3 +- .../pi-embedded-runner/run/payloads.test.ts | 9 + src/agents/pi-embedded-runner/run/payloads.ts | 36 +- src/agents/pi-embedded-runner/run/types.ts | 1 + ...pi-embedded-subscribe.handlers.messages.ts | 6 + ...ded-subscribe.handlers.tools.media.test.ts | 1 + ...-embedded-subscribe.handlers.tools.test.ts | 156 +++++++ .../pi-embedded-subscribe.handlers.tools.ts | 122 ++++- .../pi-embedded-subscribe.handlers.types.ts | 2 + src/agents/pi-embedded-subscribe.ts | 3 + src/agents/pi-embedded-subscribe.types.ts | 3 +- .../pi-tool-handler-state.test-helpers.ts | 1 + src/agents/system-prompt.ts | 3 + .../reply/agent-runner-execution.ts | 2 +- .../reply/agent-runner-utils.test.ts | 41 ++ src/auto-reply/reply/agent-runner-utils.ts | 16 +- .../agent-runner.runreplyagent.e2e.test.ts | 37 +- src/auto-reply/reply/commands-approve.ts | 38 +- src/auto-reply/reply/commands-context.ts | 1 + src/auto-reply/reply/commands.test.ts | 362 +++++---------- .../reply/dispatch-from-config.test.ts | 130 ++++++ src/auto-reply/reply/dispatch-from-config.ts | 20 + src/auto-reply/templating.ts | 2 + src/channels/plugins/outbound/telegram.ts | 1 - src/config/schema.help.quality.test.ts | 6 + src/config/schema.help.ts | 12 + src/config/schema.labels.ts | 6 + src/config/types.telegram.ts | 16 + src/config/zod-schema.providers-core.ts | 11 + src/discord/exec-approvals.ts | 23 + src/discord/monitor/exec-approvals.test.ts | 127 ++++-- src/discord/monitor/exec-approvals.ts | 41 +- src/gateway/exec-approval-manager.ts | 38 ++ .../node-invoke-system-run-approval.ts | 2 + src/gateway/server-methods/exec-approval.ts | 65 ++- .../server-methods/server-methods.test.ts | 218 +++++++-- src/gateway/server-node-events.test.ts | 17 + src/gateway/server-node-events.ts | 3 + src/infra/exec-approval-forwarder.test.ts | 132 +++++- src/infra/exec-approval-forwarder.ts | 174 ++++++-- src/infra/exec-approval-reply.ts | 172 +++++++ src/infra/exec-approval-surface.ts | 77 ++++ src/infra/outbound/deliver.test.ts | 69 +++ src/infra/outbound/deliver.ts | 20 +- src/node-host/invoke-system-run.ts | 9 +- src/node-host/invoke-types.ts | 3 + src/node-host/invoke.ts | 1 + src/telegram/approval-buttons.test.ts | 18 + src/telegram/approval-buttons.ts | 42 ++ src/telegram/bot-handlers.ts | 92 +++- src/telegram/bot-message-context.session.ts | 1 + src/telegram/bot-message-dispatch.test.ts | 45 +- src/telegram/bot-message-dispatch.ts | 16 +- .../bot-native-commands.session-meta.test.ts | 103 ++++- src/telegram/bot-native-commands.ts | 32 +- .../bot.create-telegram-bot.test-harness.ts | 5 + src/telegram/bot.test.ts | 175 +++++++- src/telegram/exec-approvals-handler.test.ts | 156 +++++++ src/telegram/exec-approvals-handler.ts | 418 ++++++++++++++++++ src/telegram/exec-approvals.test.ts | 92 ++++ src/telegram/exec-approvals.ts | 106 +++++ src/telegram/monitor.ts | 11 + src/telegram/send.test-harness.ts | 1 + src/telegram/send.test.ts | 20 + src/telegram/send.ts | 100 ++++- 78 files changed, 4058 insertions(+), 524 deletions(-) create mode 100644 src/agents/bash-tools.exec-approval-followup.ts create mode 100644 src/discord/exec-approvals.ts create mode 100644 src/infra/exec-approval-reply.ts create mode 100644 src/infra/exec-approval-surface.ts create mode 100644 src/telegram/approval-buttons.test.ts create mode 100644 src/telegram/approval-buttons.ts create mode 100644 src/telegram/exec-approvals-handler.test.ts create mode 100644 src/telegram/exec-approvals-handler.ts create mode 100644 src/telegram/exec-approvals.test.ts create mode 100644 src/telegram/exec-approvals.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b42bac2703..1e5273a8df5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index f49ea5fe3f7..a039cb43483 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -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 + + + 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) + + ## Troubleshooting @@ -859,10 +887,16 @@ Primary reference: - `channels.telegram.groups..enabled`: disable the group when `false`. - `channels.telegram.groups..topics..*`: per-topic overrides (group fields + topic-only `agentId`). - `channels.telegram.groups..topics..agentId`: route this topic to a specific agent (overrides group-level and binding routing). - - `channels.telegram.groups..topics..groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`). - - `channels.telegram.groups..topics..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..topics..agentId`: route DM topics to a specific agent (same behavior as forum topics). +- `channels.telegram.groups..topics..groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`). +- `channels.telegram.groups..topics..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..topics..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..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..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` diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index d538e411093..91fdff80650 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -309,6 +309,32 @@ Reply in chat: /approve 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 ``` diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts index 1f40a5f1cce..c1912db56f0 100644 --- a/extensions/telegram/src/channel.test.ts +++ b/extensions/telegram/src/channel.test.ts @@ -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; diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 0f4721a4d62..7ea0a7a6525 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -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 { + 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> | 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); diff --git a/src/agents/bash-tools.exec-approval-followup.ts b/src/agents/bash-tools.exec-approval-followup.ts new file mode 100644 index 00000000000..af24f07fb50 --- /dev/null +++ b/src/agents/bash-tools.exec-approval-followup.ts @@ -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 { + 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; +} diff --git a/src/agents/bash-tools.exec-host-gateway.ts b/src/agents/bash-tools.exec-host-gateway.ts index 49a958c9c5b..6b43fbe8663 100644 --- a/src/agents/bash-tools.exec-host-gateway.ts +++ b/src/agents/bash-tools.exec-host-gateway.ts @@ -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), }, }; } diff --git a/src/agents/bash-tools.exec-host-node.ts b/src/agents/bash-tools.exec-host-node.ts index b66a6ededf1..97eb4218035 100644 --- a/src/agents/bash-tools.exec-host-node.ts +++ b/src/agents/bash-tools.exec-host-node.ts @@ -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; @@ -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), }; } diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 9714e4255ee..5c3301414b9 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -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; diff --git a/src/agents/bash-tools.exec-types.ts b/src/agents/bash-tools.exec-types.ts index bef8ea4bff1..7236fdaaf47 100644 --- a/src/agents/bash-tools.exec-types.ts +++ b/src/agents/bash-tools.exec-types.ts @@ -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; }; diff --git a/src/agents/bash-tools.exec.approval-id.test.ts b/src/agents/bash-tools.exec.approval-id.test.ts index b7f4729948c..cc94f83d665 100644 --- a/src/agents/bash-tools.exec.approval-id.test.ts +++ b/src/agents/bash-tools.exec.approval-id.test.ts @@ -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> = []; + + 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); + 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, diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 381c76ada18..298bac9fe9e 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -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, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index d7fa541c2be..25f13c666c7 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -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(), diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/pi-embedded-runner/run/params.ts index 6d067c910bf..ee743d7a0c1 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -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; onReasoningEnd?: () => void | Promise; - onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise; + onToolResult?: (payload: ReplyPayload) => void | Promise; onAgentEvent?: (evt: { stream: string; data: Record }) => void; lane?: string; enqueue?: typeof enqueueCommand; diff --git a/src/agents/pi-embedded-runner/run/payloads.test.ts b/src/agents/pi-embedded-runner/run/payloads.test.ts index ee8acd1d43e..6c81fb12150 100644 --- a/src/agents/pi-embedded-runner/run/payloads.test.ts +++ b/src/agents/pi-embedded-runner/run/payloads.test.ts @@ -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); + }); }); diff --git a/src/agents/pi-embedded-runner/run/payloads.ts b/src/agents/pi-embedded-runner/run/payloads.ts index c3c87845451..16a78ec2e97 100644 --- a/src/agents/pi-embedded-runner/run/payloads.ts +++ b/src/agents/pi-embedded-runner/run/payloads.ts @@ -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) { diff --git a/src/agents/pi-embedded-runner/run/types.ts b/src/agents/pi-embedded-runner/run/types.ts index dff5aa6f251..7e6ad0578f1 100644 --- a/src/agents/pi-embedded-runner/run/types.ts +++ b/src/agents/pi-embedded-runner/run/types.ts @@ -54,6 +54,7 @@ export type EmbeddedRunAttemptResult = { actionFingerprint?: string; }; didSendViaMessagingTool: boolean; + didSendDeterministicApprovalPrompt?: boolean; messagingToolSentTexts: string[]; messagingToolSentMediaUrls: string[]; messagingToolSentTargets: MessagingToolSend[]; diff --git a/src/agents/pi-embedded-subscribe.handlers.messages.ts b/src/agents/pi-embedded-subscribe.handlers.messages.ts index c89a4b71496..04f47e67cde 100644 --- a/src/agents/pi-embedded-subscribe.handlers.messages.ts +++ b/src/agents/pi-embedded-subscribe.handlers.messages.ts @@ -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); diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts b/src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts index 741fa96c815..66685f04036 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts @@ -28,6 +28,7 @@ function createMockContext(overrides?: { messagingToolSentTextsNormalized: [], messagingToolSentMediaUrls: [], messagingToolSentTargets: [], + deterministicApprovalPromptSent: false, }, log: { debug: vi.fn(), warn: vi.fn() }, shouldEmitToolResult: vi.fn(() => false), diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.test.ts b/src/agents/pi-embedded-subscribe.handlers.tools.test.ts index 96a988e5bc6..3cf7935a8a2 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.test.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.test.ts @@ -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(); diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.ts b/src/agents/pi-embedded-subscribe.handlers.tools.ts index 8abd9469bbc..70f6b54639c 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.ts @@ -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; + const details = + outer.details && typeof outer.details === "object" && !Array.isArray(outer.details) + ? (outer.details as Record) + : 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; + const details = + outer.details && typeof outer.details === "object" && !Array.isArray(outer.details) + ? (outer.details as Record) + : 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(); diff --git a/src/agents/pi-embedded-subscribe.handlers.types.ts b/src/agents/pi-embedded-subscribe.handlers.types.ts index 955af473b9e..4436e6f6aa3 100644 --- a/src/agents/pi-embedded-subscribe.handlers.types.ts +++ b/src/agents/pi-embedded-subscribe.handlers.types.ts @@ -76,6 +76,7 @@ export type EmbeddedPiSubscribeState = { pendingMessagingTargets: Map; successfulCronAdds: number; pendingMessagingMediaUrls: Map; + deterministicApprovalPromptSent: boolean; lastAssistant?: AgentMessage; }; @@ -155,6 +156,7 @@ export type ToolHandlerState = Pick< | "messagingToolSentMediaUrls" | "messagingToolSentTargets" | "successfulCronAdds" + | "deterministicApprovalPromptSent" >; export type ToolHandlerContext = { diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index c5ffedbf14f..83592372e80 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -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, diff --git a/src/agents/pi-embedded-subscribe.types.ts b/src/agents/pi-embedded-subscribe.types.ts index 689cd49998e..bbb2d552d73 100644 --- a/src/agents/pi-embedded-subscribe.types.ts +++ b/src/agents/pi-embedded-subscribe.types.ts @@ -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; + onToolResult?: (payload: ReplyPayload) => void | Promise; onReasoningStream?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise; /** Called when a thinking/reasoning block ends ( tag processed). */ onReasoningEnd?: () => void | Promise; diff --git a/src/agents/pi-tool-handler-state.test-helpers.ts b/src/agents/pi-tool-handler-state.test-helpers.ts index 0775299ab83..cfb559b9884 100644 --- a/src/agents/pi-tool-handler-state.test-helpers.ts +++ b/src/agents/pi-tool-handler-state.test-helpers.ts @@ -10,6 +10,7 @@ export function createBaseToolHandlerState() { messagingToolSentTextsNormalized: [] as string[], messagingToolSentMediaUrls: [] as string[], messagingToolSentTargets: [] as unknown[], + deterministicApprovalPromptSent: false, blockBuffer: "", }; } diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index a3d593ab6b8..848222b7880 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -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", diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index a3b31c4ccc3..2f6c27519b0 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -445,8 +445,8 @@ export async function runAgentTurnWithFallback(params: { } await params.typingSignals.signalTextDelta(text); await onToolResult({ + ...payload, text, - mediaUrls: payload.mediaUrls, }); }) .catch((err) => { diff --git a/src/auto-reply/reply/agent-runner-utils.test.ts b/src/auto-reply/reply/agent-runner-utils.test.ts index 350c6b63e47..5bf77cd9f70 100644 --- a/src/auto-reply/reply/agent-runner-utils.test.ts +++ b/src/auto-reply/reply/agent-runner-utils.test.ts @@ -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", + }); + }); }); diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index 36e45bd9bf1..99b2b6392f6 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -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, diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts index 83c1796515c..db034ac03a6 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts @@ -21,7 +21,7 @@ type AgentRunParams = { onAssistantMessageStart?: () => Promise | void; onReasoningStream?: (payload: { text?: string }) => Promise | void; onBlockReply?: (payload: { text?: string; mediaUrls?: string[] }) => Promise | void; - onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => Promise | void; + onToolResult?: (payload: ReplyPayload) => Promise | void; onAgentEvent?: (evt: { stream: string; data: Record }) => 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"; diff --git a/src/auto-reply/reply/commands-approve.ts b/src/auto-reply/reply/commands-approve.ts index 9773ba03ad5..5b0caec9c8f 100644 --- a/src/auto-reply/reply/commands-approve.ts +++ b/src/auto-reply/reply/commands-approve.ts @@ -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 = { 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 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"], diff --git a/src/auto-reply/reply/commands-context.ts b/src/auto-reply/reply/commands-context.ts index 3d177c2b5f9..1c5056b4b46 100644 --- a/src/auto-reply/reply/commands-context.ts +++ b/src/auto-reply/reply/commands-context.ts @@ -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 { diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 38be7c43531..0f526d6edaa 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -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 => ({ - ok: false, - skipped: true, - }), - ), -); -vi.mock("../../acp/persistent-bindings.js", async () => { - const actual = await vi.importActual( - "../../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; - 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 = { diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 982557ecb68..87e77785bbb 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -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; diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 786b1a7c16b..5b250b03362 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -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; diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index cc4fc49e93f..8ca3c2389bc 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -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"; diff --git a/src/channels/plugins/outbound/telegram.ts b/src/channels/plugins/outbound/telegram.ts index 2a079a6014e..2afc67d439d 100644 --- a/src/channels/plugins/outbound/telegram.ts +++ b/src/channels/plugins/outbound/telegram.ts @@ -115,7 +115,6 @@ export const telegramOutbound: ChannelOutboundAdapter = { quoteText, mediaLocalRoots, }; - if (mediaUrls.length === 0) { const result = await send(to, text, { ...payloadOpts, diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index fa9451456bf..04d5200bfbb 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -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; diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 08c579f89e3..908829cbf33 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1383,6 +1383,18 @@ export const FIELD_HELP: Record = { "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": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 16bf21e8daf..c643cf91cd9 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -719,6 +719,12 @@ export const FIELD_LABELS: Record = { "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)", diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index ce8ad105b06..41c047e860c 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -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; + /** 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"). */ diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index ac1287460bd..3ceefb480ff 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -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, diff --git a/src/discord/exec-approvals.ts b/src/discord/exec-approvals.ts new file mode 100644 index 00000000000..f4be9a22e0c --- /dev/null +++ b/src/discord/exec-approvals.ts @@ -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 + ); +} diff --git a/src/discord/monitor/exec-approvals.test.ts b/src/discord/monitor/exec-approvals.test.ts index f5e607022ee..8f9430393a2 100644 --- a/src/discord/monitor/exec-approvals.test.ts +++ b/src/discord/monitor/exec-approvals.test.ts @@ -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", () => { diff --git a/src/discord/monitor/exec-approvals.ts b/src/discord/monitor/exec-approvals.ts index 5564b126e3c..f426ae51903 100644 --- a/src/discord/monitor/exec-approvals.ts +++ b/src/discord/monitor/exec-approvals.ts @@ -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 { diff --git a/src/gateway/exec-approval-manager.ts b/src/gateway/exec-approval-manager.ts index 320b4da0b1f..e0176470a03 100644 --- a/src/gateway/exec-approval-manager.ts +++ b/src/gateway/exec-approval-manager.ts @@ -31,6 +31,11 @@ type PendingEntry = { promise: Promise; }; +export type ExecApprovalIdLookupResult = + | { kind: "exact" | "prefix"; id: string } + | { kind: "ambiguous"; ids: string[] } + | { kind: "none" }; + export class ExecApprovalManager { private pending = new Map(); @@ -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" }; + } } diff --git a/src/gateway/node-invoke-system-run-approval.ts b/src/gateway/node-invoke-system-run-approval.ts index 1099896f6c8..b077204e4ba 100644 --- a/src/gateway/node-invoke-system-run-approval.ts +++ b/src/gateway/node-invoke-system-run-approval.ts @@ -23,6 +23,7 @@ type SystemRunParamsLike = { approved?: unknown; approvalDecision?: unknown; runId?: unknown; + suppressNotifyOnExit?: unknown; }; type ApprovalLookup = { @@ -78,6 +79,7 @@ function pickSystemRunParams(raw: Record): Record 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(), diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts index 4ea91ea247f..2292a1c808c 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -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, + ); }); }); diff --git a/src/gateway/server-node-events.test.ts b/src/gateway/server-node-events.test.ts index 46b3689642d..a8885a64a63 100644 --- a/src/gateway/server-node-events.test.ts +++ b/src/gateway/server-node-events.test.ts @@ -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", () => { diff --git a/src/gateway/server-node-events.ts b/src/gateway/server-node-events.ts index db9da55588b..3a8ad91c420 100644 --- a/src/gateway/server-node-events.ts +++ b/src/gateway/server-node-events.ts @@ -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() : ""; diff --git a/src/infra/exec-approval-forwarder.test.ts b/src/infra/exec-approval-forwarder.test.ts index f87c307c211..8ae1b53cc57 100644 --- a/src/infra/exec-approval-forwarder.test.ts +++ b/src/infra/exec-approval-forwarder.test.ts @@ -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): 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 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````"); }); }); diff --git a/src/infra/exec-approval-forwarder.ts b/src/infra/exec-approval-forwarder.ts index 296a6aa6e49..a412e2495e8 100644 --- a/src/infra/exec-approval-forwarder.ts +++ b/src/infra/exec-approval-forwarder.ts @@ -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 }; + accounts?: Record< + string, + { execApprovals?: { enabled?: boolean; approvers?: Array } } + >; + } + | 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 }; + }>(telegramConfig.accounts, accountId) as + | { execApprovals?: { enabled?: boolean; approvers?: Array } } + | 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 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 => { 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 = () => { diff --git a/src/infra/exec-approval-reply.ts b/src/infra/exec-approval-reply.ts new file mode 100644 index 00000000000..c1a3cda4a69 --- /dev/null +++ b/src/infra/exec-approval-reply.ts @@ -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; + 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"), + }; +} diff --git a/src/infra/exec-approval-surface.ts b/src/infra/exec-approval-surface.ts new file mode 100644 index 00000000000..bdefb933379 --- /dev/null +++ b/src/infra/exec-approval-surface.ts @@ -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; +} diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 7bc6d69f98a..e5b24c06a8c 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -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" }); diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 0b1f0bc72fc..caca4985370 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -300,6 +300,9 @@ function normalizePayloadForChannelDelivery( function normalizePayloadsForChannelDelivery( payloads: ReplyPayload[], channel: Exclude, + _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
, , 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; diff --git a/src/node-host/invoke-system-run.ts b/src/node-host/invoke-system-run.ts index 5fb737930a8..ab4c836bf4b 100644 --- a/src/node-host/invoke-system-run.ts +++ b/src/node-host/invoke-system-run.ts @@ -57,6 +57,7 @@ type SystemRunExecutionContext = { sessionKey: string; runId: string; cmdText: string; + suppressNotifyOnExit: boolean; }; type ResolvedExecApprovals = ReturnType; @@ -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({ diff --git a/src/node-host/invoke-types.ts b/src/node-host/invoke-types.ts index 619f86c84ff..369fd7b9c39 100644 --- a/src/node-host/invoke-types.ts +++ b/src/node-host/invoke-types.ts @@ -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 = { diff --git a/src/node-host/invoke.ts b/src/node-host/invoke.ts index bd570201eca..bb4e124a6a4 100644 --- a/src/node-host/invoke.ts +++ b/src/node-host/invoke.ts @@ -355,6 +355,7 @@ async function sendExecFinishedEvent( timedOut: params.result.timedOut, success: params.result.success, output: combined, + suppressNotifyOnExit: params.suppressNotifyOnExit, }), ); } diff --git a/src/telegram/approval-buttons.test.ts b/src/telegram/approval-buttons.test.ts new file mode 100644 index 00000000000..bc6fac49e07 --- /dev/null +++ b/src/telegram/approval-buttons.test.ts @@ -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(); + }); +}); diff --git a/src/telegram/approval-buttons.ts b/src/telegram/approval-buttons.ts new file mode 100644 index 00000000000..0439bec58b9 --- /dev/null +++ b/src/telegram/approval-buttons.ts @@ -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> = [primaryRow]; + const deny = `/approve ${approvalId} deny`; + if (allowedDecisions.includes("deny") && fitsCallbackData(deny)) { + rows.push([{ text: "Deny", callback_data: deny }]); + } + return rows; +} diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index e46e0c43fb8..78290f342ad 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -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]; diff --git a/src/telegram/bot-message-context.session.ts b/src/telegram/bot-message-context.session.ts index bde4ff3270b..6932b315dc7 100644 --- a/src/telegram/bot-message-context.session.ts +++ b/src/telegram/bot-message-context.session.ts @@ -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, diff --git a/src/telegram/bot-message-dispatch.test.ts b/src/telegram/bot-message-dispatch.test.ts index 8972532e139..7caa7cc3af7 100644 --- a/src/telegram/bot-message-dispatch.test.ts +++ b/src/telegram/bot-message-dispatch.test.ts @@ -140,6 +140,7 @@ describe("dispatchTelegramMessage draft streaming", () => { async function dispatchWithContext(params: { context: TelegramMessageContext; + cfg?: Parameters[0]["cfg"]; telegramCfg?: Parameters[0]["telegramCfg"]; streamMode?: Parameters[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 }) + ?.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); diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index d4c2f7107b6..fee56211ae5 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -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") { diff --git a/src/telegram/bot-native-commands.session-meta.test.ts b/src/telegram/bot-native-commands.session-meta.test.ts index 1b05ddd0d9c..1d1b7df5fc2 100644 --- a/src/telegram/bot-native-commands.session-meta.test.ts +++ b/src/telegram/bot-native-commands.session-meta.test.ts @@ -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[0]; +type DispatchReplyWithBufferedBlockDispatcherResult = Awaited< + ReturnType +>; +type DeliverRepliesFn = typeof import("./bot/delivery.js").deliverReplies; +type DeliverRepliesParams = Parameters[0]; + +const dispatchReplyResult: DispatchReplyWithBufferedBlockDispatcherResult = { + queuedFinal: false, + counts: {} as DispatchReplyWithBufferedBlockDispatcherResult["counts"], +}; const persistentBindingMocks = vi.hoisted(() => ({ resolveConfiguredAcpBindingRecord: vi.fn(() => null), @@ -25,7 +39,12 @@ const sessionMocks = vi.hoisted(() => ({ resolveStorePath: vi.fn(), })); const replyMocks = vi.hoisted(() => ({ - dispatchReplyWithBufferedBlockDispatcher: vi.fn(async () => undefined), + dispatchReplyWithBufferedBlockDispatcher: vi.fn( + async () => dispatchReplyResult, + ), +})); +const deliveryMocks = vi.hoisted(() => ({ + deliverReplies: vi.fn(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() { @@ -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( diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 17958daa289..aa37c98e9b9 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -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, + }); + } }); } } diff --git a/src/telegram/bot.create-telegram-bot.test-harness.ts b/src/telegram/bot.create-telegram-bot.test-harness.ts index 036d2ca60b9..b0090d62a70 100644 --- a/src/telegram/bot.create-telegram-bot.test-harness.ts +++ b/src/telegram/bot.create-telegram-bot.test-harness.ts @@ -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(); diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 69a94c3e200..043d529b408 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -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, + ) => Promise; + 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, + ) => Promise; + 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, + ) => Promise; + 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.", + {}, ); }); diff --git a/src/telegram/exec-approvals-handler.test.ts b/src/telegram/exec-approvals-handler.test.ts new file mode 100644 index 00000000000..91aa3fea217 --- /dev/null +++ b/src/telegram/exec-approvals-handler.test.ts @@ -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", + }), + ); + }); +}); diff --git a/src/telegram/exec-approvals-handler.ts b/src/telegram/exec-approvals-handler.ts new file mode 100644 index 00000000000..cc3d735e6a6 --- /dev/null +++ b/src/telegram/exec-approvals-handler.ts @@ -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(); + 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(); + 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 { + 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 { + 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 { + 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 { + 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); + } + } +} diff --git a/src/telegram/exec-approvals.test.ts b/src/telegram/exec-approvals.test.ts new file mode 100644 index 00000000000..d85e07f7187 --- /dev/null +++ b/src/telegram/exec-approvals.test.ts @@ -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["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); + }); +}); diff --git a/src/telegram/exec-approvals.ts b/src/telegram/exec-approvals.ts new file mode 100644 index 00000000000..1055e1d1676 --- /dev/null +++ b/src/telegram/exec-approvals.ts @@ -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; +} diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts index ed1e1a8744a..7131876e6f1 100644 --- a/src/telegram/monitor.ts +++ b/src/telegram/monitor.ts @@ -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(); } } diff --git a/src/telegram/send.test-harness.ts b/src/telegram/send.test-harness.ts index 57f47ac20d9..b8092034a95 100644 --- a/src/telegram/send.test-harness.ts +++ b/src/telegram/send.test-harness.ts @@ -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(), diff --git a/src/telegram/send.test.ts b/src/telegram/send.test.ts index 38097c49232..a34f27d196f 100644 --- a/src/telegram/send.test.ts +++ b/src/telegram/send.test.ts @@ -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 = [ { diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 329329a07ff..e1b352a0a61 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -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; + 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[2], + ), + "typing", + ); + return { ok: true }; +} + export async function reactMessageTelegram( chatIdInput: string | number, messageIdInput: string | number, @@ -873,6 +916,61 @@ type TelegramEditOpts = { cfg?: ReturnType; }; +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; +}; + +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, From 731f1aa9062a31f11f6bf79cafb17c2dc3794a4a Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 10 Mar 2026 08:43:07 +0530 Subject: [PATCH 5/7] test: avoid detect-secrets churn in observation fixtures --- .secrets.baseline | 8 ++++---- src/agents/pi-embedded-error-observation.test.ts | 15 +++++++++------ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index b1f909e6ca4..5a0c639b9e3 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -205,7 +205,7 @@ "filename": "apps/macos/Sources/OpenClawProtocol/GatewayModels.swift", "hashed_secret": "7990585255d25249fb1e6eac3d2bd6c37429b2cd", "is_verified": false, - "line_number": 1763 + "line_number": 1859 } ], "apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift": [ @@ -266,7 +266,7 @@ "filename": "apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift", "hashed_secret": "7990585255d25249fb1e6eac3d2bd6c37429b2cd", "is_verified": false, - "line_number": 1763 + "line_number": 1859 } ], "docs/.i18n/zh-CN.tm.jsonl": [ @@ -11659,7 +11659,7 @@ "filename": "src/agents/tools/web-search.ts", "hashed_secret": "dfba7aade0868074c2861c98e2a9a92f3178a51b", "is_verified": false, - "line_number": 292 + "line_number": 291 } ], "src/agents/tools/web-tools.enabled-defaults.e2e.test.ts": [ @@ -13013,5 +13013,5 @@ } ] }, - "generated_at": "2026-03-09T08:37:13Z" + "generated_at": "2026-03-10T03:11:06Z" } diff --git a/src/agents/pi-embedded-error-observation.test.ts b/src/agents/pi-embedded-error-observation.test.ts index 94979ebfb8c..4e1d6162d5c 100644 --- a/src/agents/pi-embedded-error-observation.test.ts +++ b/src/agents/pi-embedded-error-observation.test.ts @@ -6,6 +6,9 @@ import { sanitizeForConsole, } from "./pi-embedded-error-observation.js"; +const OBSERVATION_BEARER_TOKEN = "sk-redact-test-token"; +const OBSERVATION_COOKIE_VALUE = "session-cookie-token"; + afterEach(() => { vi.restoreAllMocks(); }); @@ -29,27 +32,27 @@ describe("buildApiErrorObservationFields", () => { it("forces token redaction for observation previews", () => { const observed = buildApiErrorObservationFields( - "Authorization: Bearer sk-abcdefghijklmnopqrstuvwxyz123456", + `Authorization: Bearer ${OBSERVATION_BEARER_TOKEN}`, ); - expect(observed.rawErrorPreview).not.toContain("sk-abcdefghijklmnopqrstuvwxyz123456"); - expect(observed.rawErrorPreview).toContain("sk-abc"); + expect(observed.rawErrorPreview).not.toContain(OBSERVATION_BEARER_TOKEN); + expect(observed.rawErrorPreview).toContain(OBSERVATION_BEARER_TOKEN.slice(0, 6)); expect(observed.rawErrorHash).toMatch(/^sha256:/); }); it("redacts observation-only header and cookie formats", () => { const observed = buildApiErrorObservationFields( - "x-api-key: sk-abcdefghijklmnopqrstuvwxyz123456 Cookie: session=abcdefghijklmnopqrstuvwxyz123456", + `x-api-key: ${OBSERVATION_BEARER_TOKEN} Cookie: session=${OBSERVATION_COOKIE_VALUE}`, ); - expect(observed.rawErrorPreview).not.toContain("abcdefghijklmnopqrstuvwxyz123456"); + expect(observed.rawErrorPreview).not.toContain(OBSERVATION_COOKIE_VALUE); expect(observed.rawErrorPreview).toContain("x-api-key: ***"); expect(observed.rawErrorPreview).toContain("Cookie: session="); }); it("does not let cookie redaction consume unrelated fields on the same line", () => { const observed = buildApiErrorObservationFields( - "Cookie: session=abcdefghijklmnopqrstuvwxyz123456 status=503 request_id=req_cookie", + `Cookie: session=${OBSERVATION_COOKIE_VALUE} status=503 request_id=req_cookie`, ); expect(observed.rawErrorPreview).toContain("Cookie: session="); From e74666cd0af0ddfb14970c81dcf2d7b470336be6 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 10 Mar 2026 08:47:56 +0530 Subject: [PATCH 6/7] build: raise extension openclaw peer floor --- extensions/googlechat/package.json | 2 +- extensions/memory-core/package.json | 2 +- pnpm-lock.yaml | 551 ++-------------------------- 3 files changed, 23 insertions(+), 532 deletions(-) diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index 2abe2abbe38..2c1db3bcd27 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -8,7 +8,7 @@ "google-auth-library": "^10.6.1" }, "peerDependencies": { - "openclaw": ">=2026.3.2" + "openclaw": ">=2026.3.7" }, "peerDependenciesMeta": { "openclaw": { diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json index ca697290047..664d0a469f4 100644 --- a/extensions/memory-core/package.json +++ b/extensions/memory-core/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw core memory search plugin", "type": "module", "peerDependencies": { - "openclaw": ">=2026.3.2" + "openclaw": ">=2026.3.7" }, "peerDependenciesMeta": { "openclaw": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ae9ea71e0c..b2043db207d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -338,8 +338,8 @@ importers: specifier: ^10.6.1 version: 10.6.1 openclaw: - specifier: '>=2026.3.2' - version: 2026.3.2(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3)) + specifier: '>=2026.3.7' + version: 2026.3.8(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3)) extensions/imessage: {} @@ -399,8 +399,8 @@ importers: extensions/memory-core: dependencies: openclaw: - specifier: '>=2026.3.2' - version: 2026.3.2(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3)) + specifier: '>=2026.3.7' + version: 2026.3.8(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3)) extensions/memory-lancedb: dependencies: @@ -618,18 +618,10 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - '@aws-sdk/client-bedrock-runtime@3.1000.0': - resolution: {integrity: sha512-GA96wgTFB4Z5vhysm+hErbgiEWZ9JqAl09BxARajL7Oanpf0KvdIjxuLp2rD/XqEIks9yG/5Rh9XIAoCUUTZXw==} - engines: {node: '>=20.0.0'} - '@aws-sdk/client-bedrock-runtime@3.1004.0': resolution: {integrity: sha512-t8cl+bPLlHZQD2Sw1a4hSLUybqJZU71+m8znkyeU8CHntFqEp2mMbuLKdHKaAYQ1fAApXMsvzenCAkDzNeeJlw==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-bedrock@3.1000.0': - resolution: {integrity: sha512-wGU8uJXrPW/hZuHdPNVe1kAFIBiKcslBcoDBN0eYBzS13um8p5jJiQJ9WsD1nSpKCmyx7qZXc6xjcbIQPyOrrA==} - engines: {node: '>=20.0.0'} - '@aws-sdk/client-bedrock@3.1004.0': resolution: {integrity: sha512-JbfZSV85IL+43S7rPBmeMbvoOYXs1wmrfbEpHkDBjkvbukRQWtoetiPAXNSKDfFq1qVsoq8sWPdoerDQwlUO8w==} engines: {node: '>=20.0.0'} @@ -718,18 +710,10 @@ packages: resolution: {integrity: sha512-g2Z9s6Y4iNh0wICaEqutgYgt/Pmhv5Ev9G3eKGFe2w9VuZDhc76vYdop6I5OocmpHV79d4TuLG+JWg5rQIVDVA==} engines: {node: '>=20.0.0'} - '@aws-sdk/eventstream-handler-node@3.972.9': - resolution: {integrity: sha512-mKPiiVssgFDWkAXdEDh8+wpr2pFSX/fBn2onXXnrfIAYbdZhYb4WilKbZ3SJMUnQi+Y48jZMam5J0RrgARluaA==} - engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-bucket-endpoint@3.972.6': resolution: {integrity: sha512-3H2bhvb7Cb/S6WFsBy/Dy9q2aegC9JmGH1inO8Lb2sWirSqpLJlZmvQHPE29h2tIxzv6el/14X/tLCQ8BQU6ZQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-eventstream@3.972.6': - resolution: {integrity: sha512-mB2+3G/oxRC+y9WRk0KCdradE2rSfxxJpcOSmAm+vDh3ex3WQHVLZ1catNIe1j5NQ+3FLBsNMRPVGkZ43PRpjw==} - engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-eventstream@3.972.7': resolution: {integrity: sha512-VWndapHYCfwLgPpCb/xwlMKG4imhFzKJzZcKOEioGn7OHY+6gdr0K7oqy1HZgbLa3ACznZ9fku+DzmAi8fUC0g==} engines: {node: '>=20.0.0'} @@ -786,10 +770,6 @@ packages: resolution: {integrity: sha512-Km90fcXt3W/iqujHzuM6IaDkYCj73gsYufcuWXApWdzoTy6KGk8fnchAjePMARU0xegIR3K4N3yIo1vy7OVe8A==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-websocket@3.972.10': - resolution: {integrity: sha512-uNqRpbL6djE+XXO4cQ+P8ra37cxNNBP+2IfkVOXu1xFdGMfW+uOTxBQuDPpP43i40PBRBXK5un79l/oYpbzYkA==} - engines: {node: '>= 14.0.0'} - '@aws-sdk/middleware-websocket@3.972.12': resolution: {integrity: sha512-iyPP6FVDKe/5wy5ojC0akpDFG1vX3FeCUU47JuwN8xfvT66xlEI8qUJZPtN55TJVFzzWZJpWL78eqUE31md08Q==} engines: {node: '>= 14.0.0'} @@ -818,10 +798,6 @@ packages: resolution: {integrity: sha512-gQYI/Buwp0CAGQxY7mR5VzkP56rkWq2Y1ROkFuXh5XY94DsSjJw62B3I0N0lysQmtwiL2ht2KHI9NylM/RP4FA==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1000.0': - resolution: {integrity: sha512-eOI+8WPtWpLdlYBGs8OCK3k5uIMUHVsNG3AFO4kaRaZcKReJ/2OO6+2O2Dd/3vTzM56kRjSKe7mBOCwa4PdYqg==} - engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1004.0': resolution: {integrity: sha512-j9BwZZId9sFp+4GPhf6KrwO8Tben2sXibZA8D1vv2I1zBdvkUHcBA2g4pkqIpTRalMTLC0NPkBPX0gERxfy/iA==} engines: {node: '>=20.0.0'} @@ -980,15 +956,9 @@ packages: '@cacheable/utils@2.3.4': resolution: {integrity: sha512-knwKUJEYgIfwShABS1BX6JyJJTglAFcEU7EXqzTdiGCXur4voqkiJkdgZIQtWNFhynzDWERcTYv/sETMu3uJWA==} - '@clack/core@1.0.1': - resolution: {integrity: sha512-WKeyK3NOBwDOzagPR5H08rFk9D/WuN705yEbuZvKqlkmoLM2woKtXb10OO2k1NoSU4SFG947i2/SCYh+2u5e4g==} - '@clack/core@1.1.0': resolution: {integrity: sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA==} - '@clack/prompts@1.0.1': - resolution: {integrity: sha512-/42G73JkuYdyWZ6m8d/CJtBrGl1Hegyc7Fy78m5Ob+jF85TOUmLR5XLce/U3LxYAw0kJ8CT5aI99RIvPHcGp/Q==} - '@clack/prompts@1.1.0': resolution: {integrity: sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g==} @@ -1222,15 +1192,6 @@ packages: '@eshaz/web-worker@1.2.2': resolution: {integrity: sha512-WxXiHFmD9u/owrzempiDlBB1ZYqiLnm9s6aPc8AlFQalq2tKmqdmMr9GXOupDgzXtqnBipj8Un0gkIm7Sjf8mw==} - '@google/genai@1.43.0': - resolution: {integrity: sha512-hklCsJNdMlDM1IwcCVcGQFBg2izY0+t5BIGbRsxi2UnKi6AGKL7pqJqmBDNRbw0bYCs4y3NA7TB+fkKfP/Nrdw==} - engines: {node: '>=20.0.0'} - peerDependencies: - '@modelcontextprotocol/sdk': ^1.25.2 - peerDependenciesMeta: - '@modelcontextprotocol/sdk': - optional: true - '@google/genai@1.44.0': resolution: {integrity: sha512-kRt9ZtuXmz+tLlcNntN/VV4LRdpl6ZOu5B1KbfNgfR65db15O6sUQcwnwLka8sT/V6qysD93fWrgJHF2L7dA9A==} engines: {node: '>=20.0.0'} @@ -1644,38 +1605,20 @@ packages: resolution: {integrity: sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw==} hasBin: true - '@mariozechner/pi-agent-core@0.55.3': - resolution: {integrity: sha512-rqbfpQ9BrP6BDiW+Ps3A8Z/p9+Md/pAfc/ECq8JP6cwnZL/jQgU355KWZKtF8zM9az1p0Q9hIWi9cQygVo6Auw==} - engines: {node: '>=20.0.0'} - '@mariozechner/pi-agent-core@0.57.1': resolution: {integrity: sha512-WXsBbkNWOObFGHkhixaT8GXJpHDd3+fn8QntYF+4R8Sa9WB90ENXWidO6b7vcKX+JX0jjO5dIsQxmzosARJKlg==} engines: {node: '>=20.0.0'} - '@mariozechner/pi-ai@0.55.3': - resolution: {integrity: sha512-f9jWoDzJR9Wy/H8JPMbjoM4WvVUeFZ65QdYA9UHIfoOopDfwWE8F8JHQOj5mmmILMacXuzsqA3J7MYqNWZRvvQ==} - engines: {node: '>=20.0.0'} - hasBin: true - '@mariozechner/pi-ai@0.57.1': resolution: {integrity: sha512-Bd/J4a3YpdzJVyHLih0vDSdB0QPL4ti0XsAwtHOK/8eVhB0fHM1CpcgIrcBFJ23TMcKXMi0qamz18ERfp8tmgg==} engines: {node: '>=20.0.0'} hasBin: true - '@mariozechner/pi-coding-agent@0.55.3': - resolution: {integrity: sha512-5SFbB7/BIp/Crjre7UNjUeNfpoU1KSW/i6LXa+ikJTBqI5LukWq2avE5l0v0M8Pg/dt1go2XCLrNFlQJiQDSPQ==} - engines: {node: '>=20.0.0'} - hasBin: true - '@mariozechner/pi-coding-agent@0.57.1': resolution: {integrity: sha512-u5MQEduj68rwVIsRsqrWkJYiJCyPph/a6bMoJAQKo1sb+Pc17Y/ojwa+wGssnUMjEB38AQKofWTVe8NFEpSWNw==} engines: {node: '>=20.6.0'} hasBin: true - '@mariozechner/pi-tui@0.55.3': - resolution: {integrity: sha512-Gh4wkYgiSPCJJaB/4wEWSL7Ga8bxSq1Crp1RPRT4vKybE/DG0W/MQr5VJDvktarxtJrD16ixScwE4dzdox/PIA==} - engines: {node: '>=20.0.0'} - '@mariozechner/pi-tui@0.57.1': resolution: {integrity: sha512-cjoRghLbeAHV0tTJeHgZXaryUi5zzBZofeZ7uJun1gztnckLLRjoVeaPTujNlc5BIfyKvFqhh1QWCZng/MXlpg==} engines: {node: '>=20.0.0'} @@ -1692,9 +1635,6 @@ packages: resolution: {integrity: sha512-570oJr93l1RcCNNaMVpOm+PgQkRgno/F65nH1aCWLIKLnw0o7iPoj+8Z5b7mnLMidg9lldVSCcf0dBxqTGE1/w==} engines: {node: '>=20.0.0'} - '@mistralai/mistralai@1.10.0': - resolution: {integrity: sha512-tdIgWs4Le8vpvPiUEWne6tK0qbVc+jMenujnvTqOjogrJUsCSQhus0tHTU1avDDh5//Rq2dFgP9mWRAdIEoBqg==} - '@mistralai/mistralai@1.14.1': resolution: {integrity: sha512-IiLmmZFCCTReQgPAT33r7KQ1nYo5JPdvGkrkZqA8qQ2qB1GHgs5LoP5K2ICyrjnpw2n8oSxMM/VP+liiKcGNlQ==} @@ -3198,93 +3138,6 @@ packages: resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} engines: {node: '>=18.0.0'} - '@snazzah/davey-android-arm-eabi@0.1.9': - resolution: {integrity: sha512-Dq0WyeVGBw+uQbisV/6PeCQV2ndJozfhZqiNIfQxu6ehIdXB7iHILv+oY+AQN2n+qxiFmLh/MOX9RF+pIWdPbA==} - engines: {node: '>= 10'} - cpu: [arm] - os: [android] - - '@snazzah/davey-android-arm64@0.1.9': - resolution: {integrity: sha512-OE16OZjv7F/JrD7Mzw5eL2gY2vXRPC8S7ZrmkcMyz/sHHJsGHlT+L7X5s56Bec1YDTVmzAsH4UBuvVBoXuIWEQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [android] - - '@snazzah/davey-darwin-arm64@0.1.9': - resolution: {integrity: sha512-z7oORvAPExikFkH6tvHhbUdZd77MYZp9VqbCpKEiI+sisWFVXgHde7F7iH3G4Bz6gUYJfgvKhWXiDRc+0SC4dg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@snazzah/davey-darwin-x64@0.1.9': - resolution: {integrity: sha512-f1LzGyRGlM414KpXml3OgWVSd7CgylcdYaFj/zDBb8bvWjxyvsI9iMeuPfe/cduloxRj8dELde/yCDZtFR6PdQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@snazzah/davey-freebsd-x64@0.1.9': - resolution: {integrity: sha512-k6p3JY2b8rD6j0V9Ql7kBUMR4eJdcpriNwiHltLzmtGuz/nK5RGQdkEP68gTLc+Uj3xs5Cy0jRKmv2xJQBR4sA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [freebsd] - - '@snazzah/davey-linux-arm-gnueabihf@0.1.9': - resolution: {integrity: sha512-xDaAFUC/1+n/YayNwKsqKOBMuW0KI6F0SjgWU+krYTQTVmAKNjOM80IjemrVoqTpBOxBsT80zEtct2wj11CE3Q==} - engines: {node: '>= 10'} - cpu: [arm] - os: [linux] - - '@snazzah/davey-linux-arm64-gnu@0.1.9': - resolution: {integrity: sha512-t1VxFBzWExPNpsNY/9oStdAAuHqFvwZvIO2YPYyVNstxfi2KmAbHMweHUW7xb2ppXuhVQZ4VGmmeXiXcXqhPBw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@snazzah/davey-linux-arm64-musl@0.1.9': - resolution: {integrity: sha512-Xvlr+nBPzuFV4PXHufddlt08JsEyu0p8mX2DpqdPxdpysYIH4I8V86yJiS4tk04a6pLBDd8IxTbBwvXJKqd/LQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@snazzah/davey-linux-x64-gnu@0.1.9': - resolution: {integrity: sha512-6Uunc/NxiEkg1reroAKZAGfOtjl1CGa7hfTTVClb2f+DiA8ZRQWBh+3lgkq/0IeL262B4F14X8QRv5Bsv128qw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@snazzah/davey-linux-x64-musl@0.1.9': - resolution: {integrity: sha512-fFQ/n3aWt1lXhxSdy+Ge3gi5bR3VETMVsWhH0gwBALUKrbo3ZzgSktm4lNrXE9i0ncMz/CDpZ5i0wt/N3XphEQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@snazzah/davey-wasm32-wasi@0.1.9': - resolution: {integrity: sha512-xWvzej8YCVlUvzlpmqJMIf0XmLlHqulKZ2e7WNe2TxQmsK+o0zTZqiQYs2MwaEbrNXBhYlHDkdpuwoXkJdscNQ==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - - '@snazzah/davey-win32-arm64-msvc@0.1.9': - resolution: {integrity: sha512-sTqry/DfltX2OdW1CTLKa3dFYN5FloAEb2yhGsY1i5+Bms6OhwByXfALvyMHYVo61Th2+sD+9BJpQffHFKDA3w==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@snazzah/davey-win32-ia32-msvc@0.1.9': - resolution: {integrity: sha512-twD3LwlkGnSwphsCtpGb5ztpBIWEvGdc0iujoVkdzZ6nJiq5p8iaLjJMO4hBm9h3s28fc+1Qd7AMVnagiOasnA==} - engines: {node: '>= 10'} - cpu: [ia32] - os: [win32] - - '@snazzah/davey-win32-x64-msvc@0.1.9': - resolution: {integrity: sha512-eMnXbv4GoTngWYY538i/qHz2BS+RgSXFsvKltPzKqnqzPzhQZIY7TemEJn3D5yWGfW4qHve9u23rz93FQqnQMA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@snazzah/davey@0.1.9': - resolution: {integrity: sha512-vNZk5y+IsxjwzTAXikvzz5pqMLb35YytC64nVF2MAFVhjpXu9ITOKUriZ0JG/llwzCAi56jb5x0cXDRIyE2A2A==} - engines: {node: '>= 10'} - '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -4210,9 +4063,6 @@ packages: discord-api-types@0.38.37: resolution: {integrity: sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w==} - discord-api-types@0.38.40: - resolution: {integrity: sha512-P/His8cotqZgQqrt+hzrocp9L8RhQQz1GkrCnC9TMJ8Uw2q0tg8YyqJyGULxhXn/8kxHETN4IppmOv+P2m82lQ==} - discord-api-types@0.38.41: resolution: {integrity: sha512-yMECyR8j9c2fVTvCQ+Qc24pweYFIZk/XoxDOmt1UvPeSw5tK6gXBd/2hhP+FEAe9Y6ny8pRMaf618XDK4U53OQ==} @@ -4614,10 +4464,6 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - grammy@1.41.0: - resolution: {integrity: sha512-CAAu74SLT+/QCg40FBhUuYJalVsxxCN3D0c31TzhFBsWWTdXrMXYjGsKngBdfvN6hQ/VzHczluj/ugZVetFNCQ==} - engines: {node: ^12.20.0 || >=14.13.1} - grammy@1.41.1: resolution: {integrity: sha512-wcHAQ1e7svL3fJMpDchcQVcWUmywhuepOOjHUHmMmWAwUJEIyK5ea5sbSjZd+Gy1aMpZeP8VYJa+4tP+j1YptQ==} engines: {node: ^12.20.0 || >=14.13.1} @@ -5466,18 +5312,6 @@ packages: oniguruma-to-es@4.3.4: resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==} - openai@6.10.0: - resolution: {integrity: sha512-ITxOGo7rO3XRMiKA5l7tQ43iNNu+iXGFAcf2t+aWVzzqRaS0i7m1K2BhxNdaveB+5eENhO0VY1FkiZzhBk4v3A==} - hasBin: true - peerDependencies: - ws: ^8.18.0 - zod: ^3.25 || ^4.0 - peerDependenciesMeta: - ws: - optional: true - zod: - optional: true - openai@6.26.0: resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==} hasBin: true @@ -5502,8 +5336,8 @@ packages: zod: optional: true - openclaw@2026.3.2: - resolution: {integrity: sha512-Gkqx24m7PF1DUXPI968DuC9n52lTZ5hI3X5PIi0HosC7J7d6RLkgVppj1mxvgiQAWMp41E41elvoi/h4KBjFcQ==} + openclaw@2026.3.8: + resolution: {integrity: sha512-e5Rk2Aj55sD/5LyX94mdYCQj7zpHXo0xIZsl+k140+nRopePfPAxC7nsu0V/NyypPRtaotP1riFfzK7IhaYkuQ==} engines: {node: '>=22.12.0'} hasBin: true peerDependencies: @@ -6746,9 +6580,6 @@ packages: zod@3.25.75: resolution: {integrity: sha512-OhpzAmVzabPOL6C3A3gpAifqr9MqihV/Msx3gor2b2kviCgcb+HM9SEOpMWwwNp9MRunWnhtAKUoo0AHhjyPPg==} - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -6818,58 +6649,6 @@ snapshots: '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 - '@aws-sdk/client-bedrock-runtime@3.1000.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.15 - '@aws-sdk/credential-provider-node': 3.972.14 - '@aws-sdk/eventstream-handler-node': 3.972.9 - '@aws-sdk/middleware-eventstream': 3.972.6 - '@aws-sdk/middleware-host-header': 3.972.6 - '@aws-sdk/middleware-logger': 3.972.6 - '@aws-sdk/middleware-recursion-detection': 3.972.6 - '@aws-sdk/middleware-user-agent': 3.972.15 - '@aws-sdk/middleware-websocket': 3.972.10 - '@aws-sdk/region-config-resolver': 3.972.6 - '@aws-sdk/token-providers': 3.1000.0 - '@aws-sdk/types': 3.973.4 - '@aws-sdk/util-endpoints': 3.996.3 - '@aws-sdk/util-user-agent-browser': 3.972.6 - '@aws-sdk/util-user-agent-node': 3.973.0 - '@smithy/config-resolver': 4.4.9 - '@smithy/core': 3.23.6 - '@smithy/eventstream-serde-browser': 4.2.10 - '@smithy/eventstream-serde-config-resolver': 4.3.10 - '@smithy/eventstream-serde-node': 4.2.10 - '@smithy/fetch-http-handler': 5.3.11 - '@smithy/hash-node': 4.2.10 - '@smithy/invalid-dependency': 4.2.10 - '@smithy/middleware-content-length': 4.2.10 - '@smithy/middleware-endpoint': 4.4.20 - '@smithy/middleware-retry': 4.4.37 - '@smithy/middleware-serde': 4.2.11 - '@smithy/middleware-stack': 4.2.10 - '@smithy/node-config-provider': 4.3.10 - '@smithy/node-http-handler': 4.4.12 - '@smithy/protocol-http': 5.3.10 - '@smithy/smithy-client': 4.12.0 - '@smithy/types': 4.13.0 - '@smithy/url-parser': 4.2.10 - '@smithy/util-base64': 4.3.1 - '@smithy/util-body-length-browser': 4.2.1 - '@smithy/util-body-length-node': 4.2.2 - '@smithy/util-defaults-mode-browser': 4.3.36 - '@smithy/util-defaults-mode-node': 4.2.39 - '@smithy/util-endpoints': 3.3.1 - '@smithy/util-middleware': 4.2.10 - '@smithy/util-retry': 4.2.10 - '@smithy/util-stream': 4.5.15 - '@smithy/util-utf8': 4.2.1 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/client-bedrock-runtime@3.1004.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -6922,51 +6701,6 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-bedrock@3.1000.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.15 - '@aws-sdk/credential-provider-node': 3.972.14 - '@aws-sdk/middleware-host-header': 3.972.6 - '@aws-sdk/middleware-logger': 3.972.6 - '@aws-sdk/middleware-recursion-detection': 3.972.6 - '@aws-sdk/middleware-user-agent': 3.972.15 - '@aws-sdk/region-config-resolver': 3.972.6 - '@aws-sdk/token-providers': 3.1000.0 - '@aws-sdk/types': 3.973.4 - '@aws-sdk/util-endpoints': 3.996.3 - '@aws-sdk/util-user-agent-browser': 3.972.6 - '@aws-sdk/util-user-agent-node': 3.973.0 - '@smithy/config-resolver': 4.4.9 - '@smithy/core': 3.23.6 - '@smithy/fetch-http-handler': 5.3.11 - '@smithy/hash-node': 4.2.10 - '@smithy/invalid-dependency': 4.2.10 - '@smithy/middleware-content-length': 4.2.10 - '@smithy/middleware-endpoint': 4.4.20 - '@smithy/middleware-retry': 4.4.37 - '@smithy/middleware-serde': 4.2.11 - '@smithy/middleware-stack': 4.2.10 - '@smithy/node-config-provider': 4.3.10 - '@smithy/node-http-handler': 4.4.12 - '@smithy/protocol-http': 5.3.10 - '@smithy/smithy-client': 4.12.0 - '@smithy/types': 4.13.0 - '@smithy/url-parser': 4.2.10 - '@smithy/util-base64': 4.3.1 - '@smithy/util-body-length-browser': 4.2.1 - '@smithy/util-body-length-node': 4.2.2 - '@smithy/util-defaults-mode-browser': 4.3.36 - '@smithy/util-defaults-mode-node': 4.2.39 - '@smithy/util-endpoints': 3.3.1 - '@smithy/util-middleware': 4.2.10 - '@smithy/util-retry': 4.2.10 - '@smithy/util-utf8': 4.2.1 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/client-bedrock@3.1004.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -7324,13 +7058,6 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/eventstream-handler-node@3.972.9': - dependencies: - '@aws-sdk/types': 3.973.4 - '@smithy/eventstream-codec': 4.2.10 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - '@aws-sdk/middleware-bucket-endpoint@3.972.6': dependencies: '@aws-sdk/types': 3.973.4 @@ -7341,13 +7068,6 @@ snapshots: '@smithy/util-config-provider': 4.2.1 tslib: 2.8.1 - '@aws-sdk/middleware-eventstream@3.972.6': - dependencies: - '@aws-sdk/types': 3.973.4 - '@smithy/protocol-http': 5.3.10 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - '@aws-sdk/middleware-eventstream@3.972.7': dependencies: '@aws-sdk/types': 3.973.5 @@ -7471,21 +7191,6 @@ snapshots: '@smithy/util-retry': 4.2.11 tslib: 2.8.1 - '@aws-sdk/middleware-websocket@3.972.10': - dependencies: - '@aws-sdk/types': 3.973.4 - '@aws-sdk/util-format-url': 3.972.6 - '@smithy/eventstream-codec': 4.2.10 - '@smithy/eventstream-serde-browser': 4.2.10 - '@smithy/fetch-http-handler': 5.3.11 - '@smithy/protocol-http': 5.3.10 - '@smithy/signature-v4': 5.3.10 - '@smithy/types': 4.13.0 - '@smithy/util-base64': 4.3.1 - '@smithy/util-hex-encoding': 4.2.1 - '@smithy/util-utf8': 4.2.1 - tslib: 2.8.1 - '@aws-sdk/middleware-websocket@3.972.12': dependencies: '@aws-sdk/types': 3.973.5 @@ -7623,18 +7328,6 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/token-providers@3.1000.0': - dependencies: - '@aws-sdk/core': 3.973.15 - '@aws-sdk/nested-clients': 3.996.3 - '@aws-sdk/types': 3.973.4 - '@smithy/property-provider': 4.2.10 - '@smithy/shared-ini-file-loader': 4.4.5 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/token-providers@3.1004.0': dependencies: '@aws-sdk/core': 3.973.18 @@ -7858,21 +7551,10 @@ snapshots: hashery: 1.5.0 keyv: 5.6.0 - '@clack/core@1.0.1': - dependencies: - picocolors: 1.1.1 - sisteransi: 1.0.5 - '@clack/core@1.1.0': dependencies: sisteransi: 1.0.5 - '@clack/prompts@1.0.1': - dependencies: - '@clack/core': 1.0.1 - picocolors: 1.1.1 - sisteransi: 1.0.5 - '@clack/prompts@1.1.0': dependencies: '@clack/core': 1.1.0 @@ -8100,17 +7782,6 @@ snapshots: '@eshaz/web-worker@1.2.2': optional: true - '@google/genai@1.43.0': - dependencies: - google-auth-library: 10.6.1 - p-retry: 4.6.2 - protobufjs: 7.5.4 - ws: 8.19.0 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - '@google/genai@1.44.0': dependencies: google-auth-library: 10.6.1 @@ -8122,21 +7793,11 @@ snapshots: - supports-color - utf-8-validate - '@grammyjs/runner@2.0.3(grammy@1.41.0)': - dependencies: - abort-controller: 3.0.0 - grammy: 1.41.0 - '@grammyjs/runner@2.0.3(grammy@1.41.1)': dependencies: abort-controller: 3.0.0 grammy: 1.41.1 - '@grammyjs/transformer-throttler@1.2.1(grammy@1.41.0)': - dependencies: - bottleneck: 2.19.5 - grammy: 1.41.0 - '@grammyjs/transformer-throttler@1.2.1(grammy@1.41.1)': dependencies: bottleneck: 2.19.5 @@ -8501,18 +8162,6 @@ snapshots: std-env: 3.10.0 yoctocolors: 2.1.2 - '@mariozechner/pi-agent-core@0.55.3(ws@8.19.0)(zod@4.3.6)': - dependencies: - '@mariozechner/pi-ai': 0.55.3(ws@8.19.0)(zod@4.3.6) - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@mariozechner/pi-agent-core@0.57.1(ws@8.19.0)(zod@4.3.6)': dependencies: '@mariozechner/pi-ai': 0.57.1(ws@8.19.0)(zod@4.3.6) @@ -8525,30 +8174,6 @@ snapshots: - ws - zod - '@mariozechner/pi-ai@0.55.3(ws@8.19.0)(zod@4.3.6)': - dependencies: - '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) - '@aws-sdk/client-bedrock-runtime': 3.1000.0 - '@google/genai': 1.43.0 - '@mistralai/mistralai': 1.10.0 - '@sinclair/typebox': 0.34.48 - ajv: 8.18.0 - ajv-formats: 3.0.1(ajv@8.18.0) - chalk: 5.6.2 - openai: 6.10.0(ws@8.19.0)(zod@4.3.6) - partial-json: 0.1.7 - proxy-agent: 6.5.0 - undici: 7.22.0 - zod-to-json-schema: 3.25.1(zod@4.3.6) - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@mariozechner/pi-ai@0.57.1(ws@8.19.0)(zod@4.3.6)': dependencies: '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) @@ -8573,37 +8198,6 @@ snapshots: - ws - zod - '@mariozechner/pi-coding-agent@0.55.3(ws@8.19.0)(zod@4.3.6)': - dependencies: - '@mariozechner/jiti': 2.6.5 - '@mariozechner/pi-agent-core': 0.55.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-ai': 0.55.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-tui': 0.55.3 - '@silvia-odwyer/photon-node': 0.3.4 - chalk: 5.6.2 - cli-highlight: 2.1.11 - diff: 8.0.3 - extract-zip: 2.0.1 - file-type: 21.3.0 - glob: 13.0.6 - hosted-git-info: 9.0.2 - ignore: 7.0.5 - marked: 15.0.12 - minimatch: 10.2.4 - proper-lockfile: 4.1.2 - strip-ansi: 7.2.0 - yaml: 2.8.2 - optionalDependencies: - '@mariozechner/clipboard': 0.3.2 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@mariozechner/pi-coding-agent@0.57.1(ws@8.19.0)(zod@4.3.6)': dependencies: '@mariozechner/jiti': 2.6.5 @@ -8636,15 +8230,6 @@ snapshots: - ws - zod - '@mariozechner/pi-tui@0.55.3': - dependencies: - '@types/mime-types': 2.1.4 - chalk: 5.6.2 - get-east-asian-width: 1.5.0 - koffi: 2.15.1 - marked: 15.0.12 - mime-types: 3.0.2 - '@mariozechner/pi-tui@0.57.1': dependencies: '@types/mime-types': 2.1.4 @@ -8684,11 +8269,6 @@ snapshots: - debug - supports-color - '@mistralai/mistralai@1.10.0': - dependencies: - zod: 3.25.76 - zod-to-json-schema: 3.25.1(zod@3.25.76) - '@mistralai/mistralai@1.14.1': dependencies: ws: 8.19.0 @@ -10291,67 +9871,6 @@ snapshots: dependencies: tslib: 2.8.1 - '@snazzah/davey-android-arm-eabi@0.1.9': - optional: true - - '@snazzah/davey-android-arm64@0.1.9': - optional: true - - '@snazzah/davey-darwin-arm64@0.1.9': - optional: true - - '@snazzah/davey-darwin-x64@0.1.9': - optional: true - - '@snazzah/davey-freebsd-x64@0.1.9': - optional: true - - '@snazzah/davey-linux-arm-gnueabihf@0.1.9': - optional: true - - '@snazzah/davey-linux-arm64-gnu@0.1.9': - optional: true - - '@snazzah/davey-linux-arm64-musl@0.1.9': - optional: true - - '@snazzah/davey-linux-x64-gnu@0.1.9': - optional: true - - '@snazzah/davey-linux-x64-musl@0.1.9': - optional: true - - '@snazzah/davey-wasm32-wasi@0.1.9': - dependencies: - '@napi-rs/wasm-runtime': 1.1.1 - optional: true - - '@snazzah/davey-win32-arm64-msvc@0.1.9': - optional: true - - '@snazzah/davey-win32-ia32-msvc@0.1.9': - optional: true - - '@snazzah/davey-win32-x64-msvc@0.1.9': - optional: true - - '@snazzah/davey@0.1.9': - optionalDependencies: - '@snazzah/davey-android-arm-eabi': 0.1.9 - '@snazzah/davey-android-arm64': 0.1.9 - '@snazzah/davey-darwin-arm64': 0.1.9 - '@snazzah/davey-darwin-x64': 0.1.9 - '@snazzah/davey-freebsd-x64': 0.1.9 - '@snazzah/davey-linux-arm-gnueabihf': 0.1.9 - '@snazzah/davey-linux-arm64-gnu': 0.1.9 - '@snazzah/davey-linux-arm64-musl': 0.1.9 - '@snazzah/davey-linux-x64-gnu': 0.1.9 - '@snazzah/davey-linux-x64-musl': 0.1.9 - '@snazzah/davey-wasm32-wasi': 0.1.9 - '@snazzah/davey-win32-arm64-msvc': 0.1.9 - '@snazzah/davey-win32-ia32-msvc': 0.1.9 - '@snazzah/davey-win32-x64-msvc': 0.1.9 - '@standard-schema/spec@1.1.0': {} '@swc/helpers@0.5.19': @@ -11364,8 +10883,6 @@ snapshots: discord-api-types@0.38.37: {} - discord-api-types@0.38.40: {} - discord-api-types@0.38.41: {} doctypes@1.1.0: {} @@ -11876,16 +11393,6 @@ snapshots: graceful-fs@4.2.11: {} - grammy@1.41.0: - dependencies: - '@grammyjs/types': 3.25.0 - abort-controller: 3.0.0 - debug: 4.4.3 - node-fetch: 2.7.0 - transitivePeerDependencies: - - encoding - - supports-color - grammy@1.41.1: dependencies: '@grammyjs/types': 3.25.0 @@ -12287,7 +11794,8 @@ snapshots: klona@2.0.6: {} - koffi@2.15.1: {} + koffi@2.15.1: + optional: true leac@0.6.0: {} @@ -12806,11 +12314,6 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 - openai@6.10.0(ws@8.19.0)(zod@4.3.6): - optionalDependencies: - ws: 8.19.0 - zod: 4.3.6 - openai@6.26.0(ws@8.19.0)(zod@4.3.6): optionalDependencies: ws: 8.19.0 @@ -12821,29 +12324,28 @@ snapshots: ws: 8.19.0 zod: 4.3.6 - openclaw@2026.3.2(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3)): + openclaw@2026.3.8(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3)): dependencies: - '@agentclientprotocol/sdk': 0.14.1(zod@4.3.6) - '@aws-sdk/client-bedrock': 3.1000.0 + '@agentclientprotocol/sdk': 0.15.0(zod@4.3.6) + '@aws-sdk/client-bedrock': 3.1004.0 '@buape/carbon': 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.5)(opusscript@0.1.1) - '@clack/prompts': 1.0.1 + '@clack/prompts': 1.1.0 '@discordjs/voice': 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1) - '@grammyjs/runner': 2.0.3(grammy@1.41.0) - '@grammyjs/transformer-throttler': 1.2.1(grammy@1.41.0) + '@grammyjs/runner': 2.0.3(grammy@1.41.1) + '@grammyjs/transformer-throttler': 1.2.1(grammy@1.41.1) '@homebridge/ciao': 1.3.5 '@larksuiteoapi/node-sdk': 1.59.0 '@line/bot-sdk': 10.6.0 '@lydell/node-pty': 1.2.0-beta.3 - '@mariozechner/pi-agent-core': 0.55.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-ai': 0.55.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-coding-agent': 0.55.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-tui': 0.55.3 + '@mariozechner/pi-agent-core': 0.57.1(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-ai': 0.57.1(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-coding-agent': 0.57.1(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-tui': 0.57.1 '@mozilla/readability': 0.6.0 '@napi-rs/canvas': 0.1.95 '@sinclair/typebox': 0.34.48 '@slack/bolt': 4.6.0(@types/express@5.0.6) '@slack/web-api': 7.14.1 - '@snazzah/davey': 0.1.9 '@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5) ajv: 8.18.0 chalk: 5.6.2 @@ -12851,13 +12353,11 @@ snapshots: cli-highlight: 2.1.11 commander: 14.0.3 croner: 10.0.1 - discord-api-types: 0.38.40 + discord-api-types: 0.38.41 dotenv: 17.3.1 express: 5.2.1 file-type: 21.3.0 - gaxios: 7.1.3 - google-auth-library: 10.6.1 - grammy: 1.41.0 + grammy: 1.41.1 https-proxy-agent: 7.0.6 ipaddr.js: 2.3.0 jiti: 2.6.1 @@ -12866,7 +12366,6 @@ snapshots: linkedom: 0.18.12 long: 5.3.2 markdown-it: 14.1.1 - node-domexception: '@nolyfill/domexception@1.0.28' node-edge-tts: 1.2.10 node-llama-cpp: 3.16.2(typescript@5.9.3) opusscript: 0.1.1 @@ -12876,16 +12375,14 @@ snapshots: qrcode-terminal: 0.12.0 sharp: 0.34.5 sqlite-vec: 0.1.7-alpha.2 - strip-ansi: 7.2.0 tar: 7.5.10 tslog: 4.10.2 undici: 7.22.0 ws: 8.19.0 yaml: 2.8.2 zod: 4.3.6 - optionalDependencies: - '@discordjs/opus': 0.10.0 transitivePeerDependencies: + - '@discordjs/opus' - '@modelcontextprotocol/sdk' - '@types/express' - audio-decode @@ -14298,18 +13795,12 @@ snapshots: - bufferutil - utf-8-validate - zod-to-json-schema@3.25.1(zod@3.25.76): - dependencies: - zod: 3.25.76 - zod-to-json-schema@3.25.1(zod@4.3.6): dependencies: zod: 4.3.6 zod@3.25.75: {} - zod@3.25.76: {} - zod@4.3.6: {} zwitch@2.0.4: {} From 391f9430cadd95a8b458f475caf2f53a5102950b Mon Sep 17 00:00:00 2001 From: Ayane <40628300+ayanesakura@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:26:06 +0800 Subject: [PATCH 7/7] fix(feishu): pass mediaLocalRoots in sendText local-image auto-convert shim (openclaw#40623) Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: ayanesakura <40628300+ayanesakura@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + extensions/feishu/src/outbound.test.ts | 2 ++ extensions/feishu/src/outbound.ts | 3 ++- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e5273a8df5..95f3ab600cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Feishu/local image auto-convert: pass `mediaLocalRoots` through the `sendText` local-image shim so allowed local image paths upload as Feishu images again instead of falling back to raw path text. (#40623) Thanks @ayanesakura. - macOS/LaunchAgent install: tighten LaunchAgent directory and plist permissions during install so launchd bootstrap does not fail when the target home path or generated plist inherited group/world-writable modes. - Gateway/Control UI: keep dashboard auth tokens in session-scoped browser storage so same-tab refreshes preserve remote token auth without restoring long-lived localStorage token persistence, while scoping tokens to the selected gateway URL and fragment-only bootstrap flow. (#40892) thanks @velvet-shark. - Models/Kimi Coding: send `anthropic-messages` tools in native Anthropic format again so `kimi-coding` stops degrading tool calls into XML/plain-text pseudo invocations instead of real `tool_use` blocks. (#38669, #39907, #40552) Thanks @opriz. diff --git a/extensions/feishu/src/outbound.test.ts b/extensions/feishu/src/outbound.test.ts index bed44df77a6..11cfc957e80 100644 --- a/extensions/feishu/src/outbound.test.ts +++ b/extensions/feishu/src/outbound.test.ts @@ -52,6 +52,7 @@ describe("feishuOutbound.sendText local-image auto-convert", () => { to: "chat_1", text: file, accountId: "main", + mediaLocalRoots: [dir], }); expect(sendMediaFeishuMock).toHaveBeenCalledWith( @@ -59,6 +60,7 @@ describe("feishuOutbound.sendText local-image auto-convert", () => { to: "chat_1", mediaUrl: file, accountId: "main", + mediaLocalRoots: [dir], }), ); expect(sendMessageFeishuMock).not.toHaveBeenCalled(); diff --git a/extensions/feishu/src/outbound.ts b/extensions/feishu/src/outbound.ts index 955777676ef..75e1fa8d42b 100644 --- a/extensions/feishu/src/outbound.ts +++ b/extensions/feishu/src/outbound.ts @@ -81,7 +81,7 @@ export const feishuOutbound: ChannelOutboundAdapter = { chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit), chunkerMode: "markdown", textChunkLimit: 4000, - sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => { + sendText: async ({ cfg, to, text, accountId, replyToId, threadId, mediaLocalRoots }) => { const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId }); // Scheme A compatibility shim: // when upstream accidentally returns a local image path as plain text, @@ -95,6 +95,7 @@ export const feishuOutbound: ChannelOutboundAdapter = { mediaUrl: localImagePath, accountId: accountId ?? undefined, replyToMessageId, + mediaLocalRoots, }); return { channel: "feishu", ...result }; } catch (err) {