From 5decb00e9d2ae36c948e4cc83e42957e83108950 Mon Sep 17 00:00:00 2001 From: Neerav Makwana Date: Mon, 9 Mar 2026 21:42:54 -0400 Subject: [PATCH 01/19] fix(swiftformat): sync GatewayModels exclusions with OpenClawProtocol (#41242) Co-authored-by: Shadow --- .swiftformat | 2 +- .swiftlint.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.swiftformat b/.swiftformat index ab608a90178..a5f551b9e35 100644 --- a/.swiftformat +++ b/.swiftformat @@ -48,4 +48,4 @@ --allman false # Exclusions ---exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/MoltbotProtocol,apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift +--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/OpenClawProtocol,apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index e4f925fdf20..567b1a1683a 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -18,7 +18,7 @@ excluded: - coverage - "*.playground" # Generated (protocol-gen-swift.ts) - - apps/macos/Sources/MoltbotProtocol/GatewayModels.swift + - apps/macos/Sources/OpenClawProtocol/GatewayModels.swift # Generated (generate-host-env-security-policy-swift.mjs) - apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift From 17201747579c27669ef0009c069d7cc9f9de7df0 Mon Sep 17 00:00:00 2001 From: Shadow Date: Mon, 9 Mar 2026 21:30:47 -0500 Subject: [PATCH 02/19] 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 03/19] 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 04/19] 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 05/19] 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 06/19] 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 07/19] 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 08/19] 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) { From e42c4f45134cd4f7325296e0234daae3611d3f56 Mon Sep 17 00:00:00 2001 From: Shadow Date: Mon, 9 Mar 2026 22:43:51 -0500 Subject: [PATCH 09/19] docs: harden PR review gates against unsubstantiated fixes --- .pi/prompts/reviewpr.md | 46 ++++++++++++++++++++++++++++++++++------- AGENTS.md | 12 +++++++++++ 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/.pi/prompts/reviewpr.md b/.pi/prompts/reviewpr.md index 835be806dd5..e3ebc0dd9c6 100644 --- a/.pi/prompts/reviewpr.md +++ b/.pi/prompts/reviewpr.md @@ -9,7 +9,20 @@ Input - If ambiguous: ask. Do (review-only) -Goal: produce a thorough review and a clear recommendation (READY for /landpr vs NEEDS WORK). Do NOT merge, do NOT push, do NOT make changes in the repo as part of this command. +Goal: produce a thorough review and a clear recommendation (READY FOR /landpr vs NEEDS WORK vs INVALID CLAIM). Do NOT merge, do NOT push, do NOT make changes in the repo as part of this command. + +0. Truthfulness + reality gate (required for bug-fix claims) + + - Do not trust the issue text or PR summary by default; verify in code and evidence. + - If the PR claims to fix a bug linked to an issue, confirm the bug exists now (repro steps, logs, failing test, or clear code-path proof). + - Prove root cause with exact location (`path/file.ts:line` + explanation of why behavior is wrong). + - Verify fix targets the same code path as the root cause. + - Require a regression test when feasible (fails before fix, passes after fix). If not feasible, require explicit justification + manual verification evidence. + - Hallucination/BS red flags (treat as BLOCKER until disproven): + - claimed behavior not present in repo, + - issue/PR says "fixes #..." but changed files do not touch implicated path, + - only docs/comments changed for a runtime bug claim, + - vague AI-generated rationale without concrete evidence. 1. Identify PR meta + context @@ -56,6 +69,7 @@ Goal: produce a thorough review and a clear recommendation (READY for /landpr vs - Any deprecations, docs, types, or lint rules we should adjust? 8. Key questions to answer explicitly + - Is the core claim substantiated by evidence, or is it likely invalid/hallucinated? - Can we fix everything ourselves in a follow-up, or does the contributor need to update this PR? - Any blocking concerns (must-fix before merge)? - Is this PR ready to land, or does it need work? @@ -65,18 +79,32 @@ Goal: produce a thorough review and a clear recommendation (READY for /landpr vs A) TL;DR recommendation -- One of: READY FOR /landpr | NEEDS WORK | NEEDS DISCUSSION +- One of: READY FOR /landpr | NEEDS WORK | INVALID CLAIM (issue/bug not substantiated) | NEEDS DISCUSSION - 1–3 sentence rationale. -B) What changed +B) Claim verification matrix (required) + +- Fill this table: + + | Field | Evidence | + |---|---| + | Claimed problem | ... | + | Evidence observed (repro/log/test/code) | ... | + | Root cause location (`path:line`) | ... | + | Why this fix addresses that root cause | ... | + | Regression coverage (test name or manual proof) | ... | + +- If any row is missing/weak, default to `NEEDS WORK` or `INVALID CLAIM`. + +C) What changed - Brief bullet summary of the diff/behavioral changes. -C) What's good +D) What's good - Bullets: correctness, simplicity, tests, docs, ergonomics, etc. -D) Concerns / questions (actionable) +E) Concerns / questions (actionable) - Numbered list. - Mark each item as: @@ -84,17 +112,19 @@ D) Concerns / questions (actionable) - IMPORTANT (should fix before merge) - NIT (optional) - For each: point to the file/area and propose a concrete fix or alternative. +- If evidence for the core bug claim is missing, add a `BLOCKER` explicitly. -E) Tests +F) Tests - What exists. - What's missing (specific scenarios). +- State clearly whether there is a regression test for the claimed bug. -F) Follow-ups (optional) +G) Follow-ups (optional) - Non-blocking refactors/tickets to open later. -G) Suggested PR comment (optional) +H) Suggested PR comment (optional) - Offer: "Want me to draft a PR comment to the author?" - If yes, provide a ready-to-paste comment summarizing the above, with clear asks. diff --git a/AGENTS.md b/AGENTS.md index 1516f2e4f58..80443603c87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,18 @@ - `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). +## PR truthfulness and bug-fix validation + +- Never merge a bug-fix PR based only on issue text, PR text, or AI rationale. +- Before `/landpr`, run `/reviewpr` and require explicit evidence for bug-fix claims. +- Minimum merge gate for bug-fix PRs: + 1. symptom evidence (repro/log/failing test), + 2. verified root cause in code with file/line, + 3. fix touches the implicated code path, + 4. regression test (fail before/pass after) when feasible; if not feasible, include manual verification proof and why no test was added. +- If claim is unsubstantiated or likely hallucinated/BS: do not merge. Request evidence/changes, or close with `invalid` when appropriate. +- If linked issue appears wrong/outdated, correct triage first; do not merge speculative fixes. + ## 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 93c44e3dad3ef0f4bcfe1f44872cac197a0baae3 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 10 Mar 2026 09:14:57 +0530 Subject: [PATCH 10/19] ci: drop gha cache from docker release (#41692) --- .github/workflows/docker-release.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index f991b7f8653..2cc29748c91 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -109,8 +109,6 @@ jobs: labels: ${{ steps.labels.outputs.value }} provenance: false push: true - cache-from: type=gha,scope=docker-release-amd64 - cache-to: type=gha,mode=max,scope=docker-release-amd64 - name: Build and push amd64 slim image id: build-slim @@ -124,8 +122,6 @@ jobs: labels: ${{ steps.labels.outputs.value }} provenance: false push: true - cache-from: type=gha,scope=docker-release-amd64 - cache-to: type=gha,mode=max,scope=docker-release-amd64 # Build arm64 images (default + slim share the build stage cache) build-arm64: @@ -214,8 +210,6 @@ jobs: labels: ${{ steps.labels.outputs.value }} provenance: false push: true - cache-from: type=gha,scope=docker-release-arm64 - cache-to: type=gha,mode=max,scope=docker-release-arm64 - name: Build and push arm64 slim image id: build-slim @@ -229,8 +223,6 @@ jobs: labels: ${{ steps.labels.outputs.value }} provenance: false push: true - cache-from: type=gha,scope=docker-release-arm64 - cache-to: type=gha,mode=max,scope=docker-release-arm64 # Create multi-platform manifests create-manifest: From f0eb67923cd74b9278b408e868b80b0db40a23e9 Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:57:03 -0500 Subject: [PATCH 11/19] fix(secrets): resolve web tool SecretRefs atomically at runtime --- CHANGELOG.md | 1 + docs/gateway/secrets.md | 3 +- docs/help/faq.md | 15 +- docs/perplexity.md | 3 + docs/reference/api-usage-costs.md | 8 +- .../reference/secretref-credential-surface.md | 4 +- ...tref-user-supplied-credentials-matrix.json | 7 + docs/tools/firecrawl.md | 3 +- docs/tools/web.md | 32 +- src/agents/openclaw-tools.ts | 4 + src/agents/openclaw-tools.web-runtime.test.ts | 135 ++++ .../tools/web-fetch.cf-markdown.test.ts | 41 + src/agents/tools/web-fetch.ts | 27 +- src/agents/tools/web-search.ts | 62 +- .../tools/web-tools.enabled-defaults.test.ts | 140 +++- src/cli/command-secret-gateway.test.ts | 113 +++ src/cli/command-secret-gateway.ts | 60 +- src/cli/command-secret-targets.test.ts | 1 + src/cli/command-secret-targets.ts | 1 + src/config/types.tools.ts | 2 +- src/gateway/server.reload.test.ts | 93 +++ src/secrets/runtime-config-collectors-core.ts | 62 -- src/secrets/runtime-shared.ts | 7 +- src/secrets/runtime-web-tools.test.ts | 451 +++++++++++ src/secrets/runtime-web-tools.ts | 705 ++++++++++++++++++ src/secrets/runtime.test.ts | 164 +++- src/secrets/runtime.ts | 16 +- src/secrets/target-registry-data.ts | 11 + 28 files changed, 2059 insertions(+), 112 deletions(-) create mode 100644 src/agents/openclaw-tools.web-runtime.test.ts create mode 100644 src/secrets/runtime-web-tools.test.ts create mode 100644 src/secrets/runtime-web-tools.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 95f3ab600cb..c19a5c2eda7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Resolve web tool SecretRefs atomically at runtime. (#41599) Thanks @joshavant. - 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. diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index 3ef08267618..e9d75343147 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -38,7 +38,8 @@ Examples of inactive surfaces: - Top-level channel credentials that no enabled account inherits. - Disabled tool/feature surfaces. - Web search provider-specific keys that are not selected by `tools.web.search.provider`. - In auto mode (provider unset), provider-specific keys are also active for provider auto-detection. + In auto mode (provider unset), keys are consulted by precedence for provider auto-detection until one resolves. + After selection, non-selected provider keys are treated as inactive until selected. - `gateway.remote.token` / `gateway.remote.password` SecretRefs are active (when `gateway.remote.enabled` is not `false`) if one of these is true: - `gateway.mode=remote` - `gateway.remote.url` is configured diff --git a/docs/help/faq.md b/docs/help/faq.md index 7dad0548fd4..a43e91f4396 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -1489,10 +1489,16 @@ Set `cli.banner.taglineMode` in config: ### How do I enable web search and web fetch -`web_fetch` works without an API key. `web_search` requires a Brave Search API -key. **Recommended:** run `openclaw configure --section web` to store it in -`tools.web.search.apiKey`. Environment alternative: set `BRAVE_API_KEY` for the -Gateway process. +`web_fetch` works without an API key. `web_search` requires a key for your +selected provider (Brave, Gemini, Grok, Kimi, or Perplexity). +**Recommended:** run `openclaw configure --section web` and choose a provider. +Environment alternatives: + +- Brave: `BRAVE_API_KEY` +- Gemini: `GEMINI_API_KEY` +- Grok: `XAI_API_KEY` +- Kimi: `KIMI_API_KEY` or `MOONSHOT_API_KEY` +- Perplexity: `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY` ```json5 { @@ -1500,6 +1506,7 @@ Gateway process. web: { search: { enabled: true, + provider: "brave", apiKey: "BRAVE_API_KEY_HERE", maxResults: 5, }, diff --git a/docs/perplexity.md b/docs/perplexity.md index bb1acef49c8..f7eccc9453e 100644 --- a/docs/perplexity.md +++ b/docs/perplexity.md @@ -71,11 +71,14 @@ Optional legacy controls: **Via config:** run `openclaw configure --section web`. It stores the key in `~/.openclaw/openclaw.json` under `tools.web.search.perplexity.apiKey`. +That field also accepts SecretRef objects. **Via environment:** set `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY` in the Gateway process environment. For a gateway install, put it in `~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables). +If `provider: "perplexity"` is configured and the Perplexity key SecretRef is unresolved with no env fallback, startup/reload fails fast. + ## Tool parameters These parameters apply to the native Perplexity Search API path. diff --git a/docs/reference/api-usage-costs.md b/docs/reference/api-usage-costs.md index dba017aacc1..baf4302ac0d 100644 --- a/docs/reference/api-usage-costs.md +++ b/docs/reference/api-usage-costs.md @@ -80,10 +80,10 @@ See [Memory](/concepts/memory). `web_search` uses API keys and may incur usage charges depending on your provider: - **Brave Search API**: `BRAVE_API_KEY` or `tools.web.search.apiKey` -- **Gemini (Google Search)**: `GEMINI_API_KEY` -- **Grok (xAI)**: `XAI_API_KEY` -- **Kimi (Moonshot)**: `KIMI_API_KEY` or `MOONSHOT_API_KEY` -- **Perplexity Search API**: `PERPLEXITY_API_KEY` +- **Gemini (Google Search)**: `GEMINI_API_KEY` or `tools.web.search.gemini.apiKey` +- **Grok (xAI)**: `XAI_API_KEY` or `tools.web.search.grok.apiKey` +- **Kimi (Moonshot)**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `tools.web.search.kimi.apiKey` +- **Perplexity Search API**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey` **Brave Search free credit:** Each Brave plan includes $5/month in renewing free credit. The Search plan costs $5 per 1,000 requests, so the credit covers diff --git a/docs/reference/secretref-credential-surface.md b/docs/reference/secretref-credential-surface.md index dd1b5f1fd2f..2a5fc5a66ac 100644 --- a/docs/reference/secretref-credential-surface.md +++ b/docs/reference/secretref-credential-surface.md @@ -31,6 +31,7 @@ Scope intent: - `talk.providers.*.apiKey` - `messages.tts.elevenlabs.apiKey` - `messages.tts.openai.apiKey` +- `tools.web.fetch.firecrawl.apiKey` - `tools.web.search.apiKey` - `tools.web.search.gemini.apiKey` - `tools.web.search.grok.apiKey` @@ -102,7 +103,8 @@ Notes: - For SecretRef-managed model providers, generated `agents/*/agent/models.json` entries persist non-secret markers (not resolved secret values) for `apiKey`/header surfaces. - For web search: - In explicit provider mode (`tools.web.search.provider` set), only the selected provider key is active. - - In auto mode (`tools.web.search.provider` unset), `tools.web.search.apiKey` and provider-specific keys are active. + - In auto mode (`tools.web.search.provider` unset), only the first provider key that resolves by precedence is active. + - In auto mode, non-selected provider refs are treated as inactive until selected. ## Unsupported credentials diff --git a/docs/reference/secretref-user-supplied-credentials-matrix.json b/docs/reference/secretref-user-supplied-credentials-matrix.json index 773ef8ab162..6d4b05d2822 100644 --- a/docs/reference/secretref-user-supplied-credentials-matrix.json +++ b/docs/reference/secretref-user-supplied-credentials-matrix.json @@ -454,6 +454,13 @@ "secretShape": "secret_input", "optIn": true }, + { + "id": "tools.web.fetch.firecrawl.apiKey", + "configFile": "openclaw.json", + "path": "tools.web.fetch.firecrawl.apiKey", + "secretShape": "secret_input", + "optIn": true + }, { "id": "tools.web.search.apiKey", "configFile": "openclaw.json", diff --git a/docs/tools/firecrawl.md b/docs/tools/firecrawl.md index e859eb2dcb1..2cd90a06bf5 100644 --- a/docs/tools/firecrawl.md +++ b/docs/tools/firecrawl.md @@ -40,7 +40,8 @@ with JS-heavy sites or pages that block plain HTTP fetches. Notes: -- `firecrawl.enabled` defaults to true when an API key is present. +- `firecrawl.enabled` defaults to `true` unless explicitly set to `false`. +- Firecrawl fallback attempts run only when an API key is available (`tools.web.fetch.firecrawl.apiKey` or `FIRECRAWL_API_KEY`). - `maxAgeMs` controls how old cached results can be (ms). Default is 2 days. ## Stealth / bot circumvention diff --git a/docs/tools/web.md b/docs/tools/web.md index 1eeb4eba7db..e77d046ce5b 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -2,7 +2,7 @@ summary: "Web search + fetch tools (Brave, Gemini, Grok, Kimi, and Perplexity providers)" read_when: - You want to enable web_search or web_fetch - - You need Brave or Perplexity Search API key setup + - You need provider API key setup - You want to use Gemini with Google Search grounding title: "Web Tools" --- @@ -49,6 +49,12 @@ The table above is alphabetical. If no `provider` is explicitly set, runtime aut If no keys are found, it falls back to Brave (you'll get a missing-key error prompting you to configure one). +Runtime SecretRef behavior: + +- Web tool SecretRefs are resolved atomically at gateway startup/reload. +- In auto-detect mode, OpenClaw resolves only the selected provider key. Non-selected provider SecretRefs stay inactive until selected. +- If the selected provider SecretRef is unresolved and no provider env fallback exists, startup/reload fails fast. + ## Setting up web search Use `openclaw configure --section web` to set up your API key and choose a provider. @@ -77,9 +83,25 @@ See [Perplexity Search API Docs](https://docs.perplexity.ai/guides/search-quicks ### Where to store the key -**Via config:** run `openclaw configure --section web`. It stores the key under `tools.web.search.apiKey` or `tools.web.search.perplexity.apiKey`, depending on provider. +**Via config:** run `openclaw configure --section web`. It stores the key under the provider-specific config path: -**Via environment:** set `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `BRAVE_API_KEY` in the Gateway process environment. For a gateway install, put it in `~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables). +- Brave: `tools.web.search.apiKey` +- Gemini: `tools.web.search.gemini.apiKey` +- Grok: `tools.web.search.grok.apiKey` +- Kimi: `tools.web.search.kimi.apiKey` +- Perplexity: `tools.web.search.perplexity.apiKey` + +All of these fields also support SecretRef objects. + +**Via environment:** set provider env vars in the Gateway process environment: + +- Brave: `BRAVE_API_KEY` +- Gemini: `GEMINI_API_KEY` +- Grok: `XAI_API_KEY` +- Kimi: `KIMI_API_KEY` or `MOONSHOT_API_KEY` +- Perplexity: `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY` + +For a gateway install, put these in `~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables). ### Config examples @@ -216,6 +238,7 @@ Search the web using your configured provider. - **Grok**: `XAI_API_KEY` or `tools.web.search.grok.apiKey` - **Kimi**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `tools.web.search.kimi.apiKey` - **Perplexity**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey` +- All provider key fields above support SecretRef objects. ### Config @@ -310,6 +333,7 @@ Fetch a URL and extract readable content. - `tools.web.fetch.enabled` must not be `false` (default: enabled) - Optional Firecrawl fallback: set `tools.web.fetch.firecrawl.apiKey` or `FIRECRAWL_API_KEY`. +- `tools.web.fetch.firecrawl.apiKey` supports SecretRef objects. ### web_fetch config @@ -351,6 +375,8 @@ Notes: - `web_fetch` uses Readability (main-content extraction) first, then Firecrawl (if configured). If both fail, the tool returns an error. - Firecrawl requests use bot-circumvention mode and cache results by default. +- Firecrawl SecretRefs are resolved only when Firecrawl is active (`tools.web.fetch.enabled !== false` and `tools.web.fetch.firecrawl.enabled !== false`). +- If Firecrawl is active and its SecretRef is unresolved with no `FIRECRAWL_API_KEY` fallback, startup/reload fails fast. - `web_fetch` sends a Chrome-like User-Agent and `Accept-Language` by default; override `userAgent` if needed. - `web_fetch` blocks private/internal hostnames and re-checks redirects (limit with `maxRedirects`). - `maxChars` is clamped to `tools.web.fetch.maxCharsCap`. diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 17f8e6dadb4..56d0801d13c 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolvePluginTools } from "../plugins/tools.js"; +import { getActiveRuntimeWebToolsMetadata } from "../secrets/runtime.js"; import type { GatewayMessageChannel } from "../utils/message-channel.js"; import { resolveSessionAgentId } from "./agent-scope.js"; import type { SandboxFsBridge } from "./sandbox/fs-bridge.js"; @@ -72,6 +73,7 @@ export function createOpenClawTools( } & SpawnedToolContext, ): AnyAgentTool[] { const workspaceDir = resolveWorkspaceRoot(options?.workspaceDir); + const runtimeWebTools = getActiveRuntimeWebToolsMetadata(); const imageTool = options?.agentDir?.trim() ? createImageTool({ config: options?.config, @@ -100,10 +102,12 @@ export function createOpenClawTools( const webSearchTool = createWebSearchTool({ config: options?.config, sandboxed: options?.sandboxed, + runtimeWebSearch: runtimeWebTools?.search, }); const webFetchTool = createWebFetchTool({ config: options?.config, sandboxed: options?.sandboxed, + runtimeFirecrawl: runtimeWebTools?.fetch.firecrawl, }); const messageTool = options?.disableMessageTool ? null diff --git a/src/agents/openclaw-tools.web-runtime.test.ts b/src/agents/openclaw-tools.web-runtime.test.ts new file mode 100644 index 00000000000..94478930cf1 --- /dev/null +++ b/src/agents/openclaw-tools.web-runtime.test.ts @@ -0,0 +1,135 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { + activateSecretsRuntimeSnapshot, + clearSecretsRuntimeSnapshot, + prepareSecretsRuntimeSnapshot, +} from "../secrets/runtime.js"; +import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; +import { createOpenClawTools } from "./openclaw-tools.js"; + +vi.mock("../plugins/tools.js", () => ({ + resolvePluginTools: () => [], +})); + +function asConfig(value: unknown): OpenClawConfig { + return value as OpenClawConfig; +} + +function findTool(name: string, config: OpenClawConfig) { + const allTools = createOpenClawTools({ config, sandboxed: true }); + const tool = allTools.find((candidate) => candidate.name === name); + expect(tool).toBeDefined(); + if (!tool) { + throw new Error(`missing ${name} tool`); + } + return tool; +} + +function makeHeaders(map: Record): { get: (key: string) => string | null } { + return { + get: (key) => map[key.toLowerCase()] ?? null, + }; +} + +async function prepareAndActivate(params: { config: OpenClawConfig; env?: NodeJS.ProcessEnv }) { + const snapshot = await prepareSecretsRuntimeSnapshot({ + config: params.config, + env: params.env, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => ({ version: 1, profiles: {} }), + }); + activateSecretsRuntimeSnapshot(snapshot); + return snapshot; +} + +describe("openclaw tools runtime web metadata wiring", () => { + const priorFetch = global.fetch; + + afterEach(() => { + global.fetch = priorFetch; + clearSecretsRuntimeSnapshot(); + }); + + it("uses runtime-selected provider when higher-precedence provider ref is unresolved", async () => { + const snapshot = await prepareAndActivate({ + config: asConfig({ + tools: { + web: { + search: { + apiKey: { source: "env", provider: "default", id: "MISSING_BRAVE_KEY_REF" }, + gemini: { + apiKey: { source: "env", provider: "default", id: "GEMINI_WEB_KEY_REF" }, + }, + }, + }, + }, + }), + env: { + GEMINI_WEB_KEY_REF: "gemini-runtime-key", + }, + }); + + expect(snapshot.webTools.search.selectedProvider).toBe("gemini"); + + const mockFetch = vi.fn((_input?: unknown, _init?: unknown) => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + candidates: [ + { + content: { parts: [{ text: "runtime gemini ok" }] }, + groundingMetadata: { groundingChunks: [] }, + }, + ], + }), + } as Response), + ); + global.fetch = withFetchPreconnect(mockFetch); + + const webSearch = findTool("web_search", snapshot.config); + const result = await webSearch.execute("call-runtime-search", { query: "runtime search" }); + + expect(mockFetch).toHaveBeenCalled(); + expect(String(mockFetch.mock.calls[0]?.[0])).toContain("generativelanguage.googleapis.com"); + expect((result.details as { provider?: string }).provider).toBe("gemini"); + }); + + it("skips Firecrawl key resolution when runtime marks Firecrawl inactive", async () => { + const snapshot = await prepareAndActivate({ + config: asConfig({ + tools: { + web: { + fetch: { + firecrawl: { + enabled: false, + apiKey: { source: "env", provider: "default", id: "MISSING_FIRECRAWL_KEY_REF" }, + }, + }, + }, + }, + }), + }); + + const mockFetch = vi.fn((_input?: unknown, _init?: unknown) => + Promise.resolve({ + ok: true, + status: 200, + headers: makeHeaders({ "content-type": "text/html; charset=utf-8" }), + text: () => + Promise.resolve( + "

Runtime Off

Use direct fetch.

", + ), + } as Response), + ); + global.fetch = withFetchPreconnect(mockFetch); + + const webFetch = findTool("web_fetch", snapshot.config); + await webFetch.execute("call-runtime-fetch", { url: "https://example.com/runtime-off" }); + + expect(mockFetch).toHaveBeenCalled(); + expect(mockFetch.mock.calls[0]?.[0]).toBe("https://example.com/runtime-off"); + expect(String(mockFetch.mock.calls[0]?.[0])).not.toContain("api.firecrawl.dev"); + }); +}); diff --git a/src/agents/tools/web-fetch.cf-markdown.test.ts b/src/agents/tools/web-fetch.cf-markdown.test.ts index 6e7768fc43a..e235177a309 100644 --- a/src/agents/tools/web-fetch.cf-markdown.test.ts +++ b/src/agents/tools/web-fetch.cf-markdown.test.ts @@ -84,6 +84,47 @@ describe("web_fetch Cloudflare Markdown for Agents", () => { expect(details?.contentType).toBe("text/html"); }); + it("bypasses Firecrawl when runtime metadata marks Firecrawl inactive", async () => { + const fetchSpy = vi + .fn() + .mockResolvedValue( + htmlResponse( + "

Runtime Off

Use direct fetch.

", + ), + ); + global.fetch = withFetchPreconnect(fetchSpy); + + const tool = createWebFetchTool({ + config: { + tools: { + web: { + fetch: { + firecrawl: { + enabled: true, + apiKey: { + source: "env", + provider: "default", + id: "MISSING_FIRECRAWL_KEY_REF", + }, + }, + }, + }, + }, + }, + sandboxed: false, + runtimeFirecrawl: { + active: false, + apiKeySource: "secretRef", + diagnostics: [], + }, + }); + + await tool?.execute?.("call", { url: "https://example.com/runtime-firecrawl-off" }); + + expect(fetchSpy).toHaveBeenCalled(); + expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://example.com/runtime-firecrawl-off"); + }); + it("logs x-markdown-tokens when header is present", async () => { const logSpy = vi.spyOn(logger, "logDebug").mockImplementation(() => {}); const fetchSpy = vi diff --git a/src/agents/tools/web-fetch.ts b/src/agents/tools/web-fetch.ts index 4ac7a1d7bfd..f4cc88e2d83 100644 --- a/src/agents/tools/web-fetch.ts +++ b/src/agents/tools/web-fetch.ts @@ -1,7 +1,9 @@ import { Type } from "@sinclair/typebox"; import type { OpenClawConfig } from "../../config/config.js"; +import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js"; import { SsrFBlockedError } from "../../infra/net/ssrf.js"; import { logDebug } from "../../logger.js"; +import type { RuntimeWebFetchFirecrawlMetadata } from "../../secrets/runtime-web-tools.js"; import { wrapExternalContent, wrapWebContent } from "../../security/external-content.js"; import { normalizeSecretInput } from "../../utils/normalize-secret-input.js"; import { stringEnum } from "../schema/typebox.js"; @@ -71,7 +73,7 @@ type WebFetchConfig = NonNullable["web"] extends infer type FirecrawlFetchConfig = | { enabled?: boolean; - apiKey?: string; + apiKey?: unknown; baseUrl?: string; onlyMainContent?: boolean; maxAgeMs?: number; @@ -136,10 +138,14 @@ function resolveFirecrawlConfig(fetch?: WebFetchConfig): FirecrawlFetchConfig { } function resolveFirecrawlApiKey(firecrawl?: FirecrawlFetchConfig): string | undefined { - const fromConfig = - firecrawl && "apiKey" in firecrawl && typeof firecrawl.apiKey === "string" - ? normalizeSecretInput(firecrawl.apiKey) - : ""; + const fromConfigRaw = + firecrawl && "apiKey" in firecrawl + ? normalizeResolvedSecretInputString({ + value: firecrawl.apiKey, + path: "tools.web.fetch.firecrawl.apiKey", + }) + : undefined; + const fromConfig = normalizeSecretInput(fromConfigRaw); const fromEnv = normalizeSecretInput(process.env.FIRECRAWL_API_KEY); return fromConfig || fromEnv || undefined; } @@ -712,6 +718,7 @@ function resolveFirecrawlEndpoint(baseUrl: string): string { export function createWebFetchTool(options?: { config?: OpenClawConfig; sandboxed?: boolean; + runtimeFirecrawl?: RuntimeWebFetchFirecrawlMetadata; }): AnyAgentTool | null { const fetch = resolveFetchConfig(options?.config); if (!resolveFetchEnabled({ fetch, sandboxed: options?.sandboxed })) { @@ -719,8 +726,14 @@ export function createWebFetchTool(options?: { } const readabilityEnabled = resolveFetchReadabilityEnabled(fetch); const firecrawl = resolveFirecrawlConfig(fetch); - const firecrawlApiKey = resolveFirecrawlApiKey(firecrawl); - const firecrawlEnabled = resolveFirecrawlEnabled({ firecrawl, apiKey: firecrawlApiKey }); + const runtimeFirecrawlActive = options?.runtimeFirecrawl?.active; + const shouldResolveFirecrawlApiKey = + runtimeFirecrawlActive === undefined ? firecrawl?.enabled !== false : runtimeFirecrawlActive; + const firecrawlApiKey = shouldResolveFirecrawlApiKey + ? resolveFirecrawlApiKey(firecrawl) + : undefined; + const firecrawlEnabled = + runtimeFirecrawlActive ?? resolveFirecrawlEnabled({ firecrawl, apiKey: firecrawlApiKey }); const firecrawlBaseUrl = resolveFirecrawlBaseUrl(firecrawl); const firecrawlOnlyMainContent = resolveFirecrawlOnlyMainContent(firecrawl); const firecrawlMaxAgeMs = resolveFirecrawlMaxAgeMsOrDefault(firecrawl); diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index d4f88caea61..4fbbfa95e43 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -3,6 +3,7 @@ import { formatCliCommand } from "../../cli/command-format.js"; import type { OpenClawConfig } from "../../config/config.js"; import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js"; import { logVerbose } from "../../globals.js"; +import type { RuntimeWebSearchMetadata } from "../../secrets/runtime-web-tools.js"; import { wrapWebContent } from "../../security/external-content.js"; import { normalizeSecretInput } from "../../utils/normalize-secret-input.js"; import type { AnyAgentTool } from "./common.js"; @@ -193,6 +194,33 @@ function createWebSearchSchema(params: { ), } as const; + const perplexityStructuredFilterSchema = { + country: Type.Optional( + Type.String({ + description: + "Native Perplexity Search API only. 2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.", + }), + ), + language: Type.Optional( + Type.String({ + description: + "Native Perplexity Search API only. ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').", + }), + ), + date_after: Type.Optional( + Type.String({ + description: + "Native Perplexity Search API only. Only results published after this date (YYYY-MM-DD).", + }), + ), + date_before: Type.Optional( + Type.String({ + description: + "Native Perplexity Search API only. Only results published before this date (YYYY-MM-DD).", + }), + ), + } as const; + if (params.provider === "brave") { return Type.Object({ ...querySchema, @@ -221,7 +249,8 @@ function createWebSearchSchema(params: { } return Type.Object({ ...querySchema, - ...filterSchema, + freshness: filterSchema.freshness, + ...perplexityStructuredFilterSchema, domain_filter: Type.Optional( Type.Array(Type.String(), { description: @@ -742,6 +771,16 @@ function resolvePerplexityTransport(perplexity?: PerplexityConfig): { }; } +function resolvePerplexitySchemaTransportHint( + perplexity?: PerplexityConfig, +): PerplexityTransport | undefined { + const hasLegacyOverride = Boolean( + (perplexity?.baseUrl && perplexity.baseUrl.trim()) || + (perplexity?.model && perplexity.model.trim()), + ); + return hasLegacyOverride ? "chat_completions" : undefined; +} + function resolveGrokConfig(search?: WebSearchConfig): GrokConfig { if (!search || typeof search !== "object") { return {}; @@ -1809,15 +1848,21 @@ async function runWebSearch(params: { export function createWebSearchTool(options?: { config?: OpenClawConfig; sandboxed?: boolean; + runtimeWebSearch?: RuntimeWebSearchMetadata; }): AnyAgentTool | null { const search = resolveSearchConfig(options?.config); if (!resolveSearchEnabled({ search, sandboxed: options?.sandboxed })) { return null; } - const provider = resolveSearchProvider(search); + const provider = + options?.runtimeWebSearch?.selectedProvider ?? + options?.runtimeWebSearch?.providerConfigured ?? + resolveSearchProvider(search); const perplexityConfig = resolvePerplexityConfig(search); - const perplexityTransport = resolvePerplexityTransport(perplexityConfig); + const perplexitySchemaTransportHint = + options?.runtimeWebSearch?.perplexityTransport ?? + resolvePerplexitySchemaTransportHint(perplexityConfig); const grokConfig = resolveGrokConfig(search); const geminiConfig = resolveGeminiConfig(search); const kimiConfig = resolveKimiConfig(search); @@ -1826,9 +1871,9 @@ export function createWebSearchTool(options?: { const description = provider === "perplexity" - ? perplexityTransport.transport === "chat_completions" + ? perplexitySchemaTransportHint === "chat_completions" ? "Search the web using Perplexity Sonar via Perplexity/OpenRouter chat completions. Returns AI-synthesized answers with citations from web-grounded search." - : "Search the web using the Perplexity Search API. Returns structured results (title, URL, snippet) for fast research. Supports domain, region, language, and freshness filtering." + : "Search the web using Perplexity. Runtime routing decides between native Search API and Sonar chat-completions compatibility. Structured filters are available on the native Search API path." : provider === "grok" ? "Search the web using xAI Grok. Returns AI-synthesized answers with citations from real-time web search." : provider === "kimi" @@ -1845,10 +1890,13 @@ export function createWebSearchTool(options?: { description, parameters: createWebSearchSchema({ provider, - perplexityTransport: provider === "perplexity" ? perplexityTransport.transport : undefined, + perplexityTransport: provider === "perplexity" ? perplexitySchemaTransportHint : undefined, }), execute: async (_toolCallId, args) => { - const perplexityRuntime = provider === "perplexity" ? perplexityTransport : undefined; + // Resolve Perplexity auth/transport lazily at execution time so unrelated providers + // do not touch Perplexity-only credential surfaces during tool construction. + const perplexityRuntime = + provider === "perplexity" ? resolvePerplexityTransport(perplexityConfig) : undefined; const apiKey = provider === "perplexity" ? perplexityRuntime?.apiKey diff --git a/src/agents/tools/web-tools.enabled-defaults.test.ts b/src/agents/tools/web-tools.enabled-defaults.test.ts index 80dcd6a025d..4951f1c6b5a 100644 --- a/src/agents/tools/web-tools.enabled-defaults.test.ts +++ b/src/agents/tools/web-tools.enabled-defaults.test.ts @@ -166,6 +166,39 @@ describe("web tools defaults", () => { const tool = createWebSearchTool({ config: {}, sandboxed: false }); expect(tool?.name).toBe("web_search"); }); + + it("prefers runtime-selected web_search provider over local provider config", async () => { + const mockFetch = installMockFetch(createProviderSuccessPayload("gemini")); + const tool = createWebSearchTool({ + config: { + tools: { + web: { + search: { + provider: "brave", + apiKey: "brave-config-test", // pragma: allowlist secret + gemini: { + apiKey: "gemini-config-test", // pragma: allowlist secret + }, + }, + }, + }, + }, + sandboxed: true, + runtimeWebSearch: { + providerConfigured: "brave", + providerSource: "auto-detect", + selectedProvider: "gemini", + selectedProviderKeySource: "secretRef", + diagnostics: [], + }, + }); + + const result = await tool?.execute?.("call-runtime-provider", { query: "runtime override" }); + + expect(mockFetch).toHaveBeenCalled(); + expect(String(mockFetch.mock.calls[0]?.[0])).toContain("generativelanguage.googleapis.com"); + expect((result?.details as { provider?: string } | undefined)?.provider).toBe("gemini"); + }); }); describe("web_search country and language parameters", () => { @@ -489,20 +522,56 @@ describe("web_search perplexity OpenRouter compatibility", () => { expect(result?.details).toMatchObject({ error: "unsupported_domain_filter" }); }); - it("hides Search API-only schema params on the compatibility path", () => { + it("keeps Search API schema params visible before runtime auth routing", () => { vi.stubEnv("OPENROUTER_API_KEY", "sk-or-v1-test"); // pragma: allowlist secret const tool = createPerplexitySearchTool(); const properties = (tool?.parameters as { properties?: Record } | undefined) ?.properties; expect(properties?.freshness).toBeDefined(); - expect(properties?.country).toBeUndefined(); - expect(properties?.language).toBeUndefined(); - expect(properties?.date_after).toBeUndefined(); - expect(properties?.date_before).toBeUndefined(); - expect(properties?.domain_filter).toBeUndefined(); - expect(properties?.max_tokens).toBeUndefined(); - expect(properties?.max_tokens_per_page).toBeUndefined(); + expect(properties?.country).toBeDefined(); + expect(properties?.language).toBeDefined(); + expect(properties?.date_after).toBeDefined(); + expect(properties?.date_before).toBeDefined(); + expect(properties?.domain_filter).toBeDefined(); + expect(properties?.max_tokens).toBeDefined(); + expect(properties?.max_tokens_per_page).toBeDefined(); + expect( + ( + properties?.country as + | { + description?: string; + } + | undefined + )?.description, + ).toContain("Native Perplexity Search API only."); + expect( + ( + properties?.language as + | { + description?: string; + } + | undefined + )?.description, + ).toContain("Native Perplexity Search API only."); + expect( + ( + properties?.date_after as + | { + description?: string; + } + | undefined + )?.description, + ).toContain("Native Perplexity Search API only."); + expect( + ( + properties?.date_before as + | { + description?: string; + } + | undefined + )?.description, + ).toContain("Native Perplexity Search API only."); }); it("keeps structured schema params on the native Search API path", () => { @@ -522,6 +591,61 @@ describe("web_search perplexity OpenRouter compatibility", () => { }); }); +describe("web_search Perplexity lazy resolution", () => { + const priorFetch = global.fetch; + + afterEach(() => { + vi.unstubAllEnvs(); + global.fetch = priorFetch; + }); + + it("does not read Perplexity credentials while creating non-Perplexity tools", () => { + const perplexityConfig: Record = {}; + Object.defineProperty(perplexityConfig, "apiKey", { + enumerable: true, + get() { + throw new Error("perplexity-apiKey-getter-called"); + }, + }); + + const tool = createWebSearchTool({ + config: { + tools: { + web: { + search: { + provider: "gemini", + gemini: { apiKey: "gemini-config-test" }, + perplexity: perplexityConfig as { apiKey?: string; baseUrl?: string; model?: string }, + }, + }, + }, + }, + sandboxed: true, + }); + + expect(tool?.name).toBe("web_search"); + }); + + it("defers Perplexity credential reads until execute", async () => { + const perplexityConfig: Record = {}; + Object.defineProperty(perplexityConfig, "apiKey", { + enumerable: true, + get() { + throw new Error("perplexity-apiKey-getter-called"); + }, + }); + + const tool = createPerplexitySearchTool( + perplexityConfig as { apiKey?: string; baseUrl?: string; model?: string }, + ); + + expect(tool?.name).toBe("web_search"); + await expect(tool?.execute?.("call-1", { query: "test" })).rejects.toThrow( + /perplexity-apiKey-getter-called/, + ); + }); +}); + describe("web_search kimi provider", () => { const priorFetch = global.fetch; diff --git a/src/cli/command-secret-gateway.test.ts b/src/cli/command-secret-gateway.test.ts index 7929cdbdafc..6d0f89f6349 100644 --- a/src/cli/command-secret-gateway.test.ts +++ b/src/cli/command-secret-gateway.test.ts @@ -206,6 +206,119 @@ describe("resolveCommandSecretRefsViaGateway", () => { } }); + it("falls back to local resolution for web search SecretRefs when gateway is unavailable", async () => { + const envKey = "WEB_SEARCH_GEMINI_API_KEY_LOCAL_FALLBACK"; + const priorValue = process.env[envKey]; + process.env[envKey] = "gemini-local-fallback-key"; + callGateway.mockRejectedValueOnce(new Error("gateway closed")); + + try { + const result = await resolveCommandSecretRefsViaGateway({ + config: { + tools: { + web: { + search: { + provider: "gemini", + gemini: { + apiKey: { source: "env", provider: "default", id: envKey }, + }, + }, + }, + }, + } as OpenClawConfig, + commandName: "agent", + targetIds: new Set(["tools.web.search.gemini.apiKey"]), + }); + + expect(result.resolvedConfig.tools?.web?.search?.gemini?.apiKey).toBe( + "gemini-local-fallback-key", + ); + expect(result.targetStatesByPath["tools.web.search.gemini.apiKey"]).toBe("resolved_local"); + expect( + result.diagnostics.some((entry) => entry.includes("gateway secrets.resolve unavailable")), + ).toBe(true); + expect( + result.diagnostics.some((entry) => entry.includes("resolved command secrets locally")), + ).toBe(true); + } finally { + if (priorValue === undefined) { + delete process.env[envKey]; + } else { + process.env[envKey] = priorValue; + } + } + }); + + it("falls back to local resolution for Firecrawl SecretRefs when gateway is unavailable", async () => { + const envKey = "WEB_FETCH_FIRECRAWL_API_KEY_LOCAL_FALLBACK"; + const priorValue = process.env[envKey]; + process.env[envKey] = "firecrawl-local-fallback-key"; + callGateway.mockRejectedValueOnce(new Error("gateway closed")); + + try { + const result = await resolveCommandSecretRefsViaGateway({ + config: { + tools: { + web: { + fetch: { + firecrawl: { + apiKey: { source: "env", provider: "default", id: envKey }, + }, + }, + }, + }, + } as OpenClawConfig, + commandName: "agent", + targetIds: new Set(["tools.web.fetch.firecrawl.apiKey"]), + }); + + expect(result.resolvedConfig.tools?.web?.fetch?.firecrawl?.apiKey).toBe( + "firecrawl-local-fallback-key", + ); + expect(result.targetStatesByPath["tools.web.fetch.firecrawl.apiKey"]).toBe("resolved_local"); + expect( + result.diagnostics.some((entry) => entry.includes("gateway secrets.resolve unavailable")), + ).toBe(true); + expect( + result.diagnostics.some((entry) => entry.includes("resolved command secrets locally")), + ).toBe(true); + } finally { + if (priorValue === undefined) { + delete process.env[envKey]; + } else { + process.env[envKey] = priorValue; + } + } + }); + + it("marks web SecretRefs inactive when the web surface is disabled during local fallback", async () => { + callGateway.mockRejectedValueOnce(new Error("gateway closed")); + const result = await resolveCommandSecretRefsViaGateway({ + config: { + tools: { + web: { + search: { + enabled: false, + gemini: { + apiKey: { source: "env", provider: "default", id: "WEB_SEARCH_DISABLED_KEY" }, + }, + }, + }, + }, + } as OpenClawConfig, + commandName: "agent", + targetIds: new Set(["tools.web.search.gemini.apiKey"]), + }); + + expect(result.hadUnresolvedTargets).toBe(false); + expect(result.targetStatesByPath["tools.web.search.gemini.apiKey"]).toBe("inactive_surface"); + expect( + result.diagnostics.some((entry) => + entry.includes("tools.web.search.gemini.apiKey: tools.web.search is disabled."), + ), + ).toBe(true); + }); + it("returns a version-skew hint when gateway does not support secrets.resolve", async () => { const envKey = "TALK_API_KEY_UNSUPPORTED"; callGateway.mockRejectedValueOnce(new Error("unknown method: secrets.resolve")); diff --git a/src/cli/command-secret-gateway.ts b/src/cli/command-secret-gateway.ts index 89b8c78a3e3..03e578b642c 100644 --- a/src/cli/command-secret-gateway.ts +++ b/src/cli/command-secret-gateway.ts @@ -10,6 +10,7 @@ import { getPath, setPathExistingStrict } from "../secrets/path-utils.js"; import { resolveSecretRefValue } from "../secrets/resolve.js"; import { collectConfigAssignments } from "../secrets/runtime-config-collectors.js"; import { createResolverContext } from "../secrets/runtime-shared.js"; +import { resolveRuntimeWebTools } from "../secrets/runtime-web-tools.js"; import { assertExpectedResolvedSecretValue } from "../secrets/secret-value.js"; import { describeUnknownError } from "../secrets/shared.js"; import { @@ -44,6 +45,15 @@ type GatewaySecretsResolveResult = { inactiveRefPaths?: string[]; }; +const WEB_RUNTIME_SECRET_TARGET_ID_PREFIXES = [ + "tools.web.search", + "tools.web.fetch.firecrawl", +] as const; +const WEB_RUNTIME_SECRET_PATH_PREFIXES = [ + "tools.web.search.", + "tools.web.fetch.firecrawl.", +] as const; + function dedupeDiagnostics(entries: readonly string[]): string[] { const seen = new Set(); const ordered: string[] = []; @@ -58,6 +68,30 @@ function dedupeDiagnostics(entries: readonly string[]): string[] { return ordered; } +function targetsRuntimeWebPath(path: string): boolean { + return WEB_RUNTIME_SECRET_PATH_PREFIXES.some((prefix) => path.startsWith(prefix)); +} + +function targetsRuntimeWebResolution(params: { + targetIds: ReadonlySet; + allowedPaths?: ReadonlySet; +}): boolean { + if (params.allowedPaths) { + for (const path of params.allowedPaths) { + if (targetsRuntimeWebPath(path)) { + return true; + } + } + return false; + } + for (const targetId of params.targetIds) { + if (WEB_RUNTIME_SECRET_TARGET_ID_PREFIXES.some((prefix) => targetId.startsWith(prefix))) { + return true; + } + } + return false; +} + function collectConfiguredTargetRefPaths(params: { config: OpenClawConfig; targetIds: Set; @@ -193,17 +227,40 @@ async function resolveCommandSecretRefsLocally(params: { sourceConfig, env: process.env, }); + const localResolutionDiagnostics: string[] = []; collectConfigAssignments({ config: structuredClone(params.config), context, }); + if ( + targetsRuntimeWebResolution({ targetIds: params.targetIds, allowedPaths: params.allowedPaths }) + ) { + try { + await resolveRuntimeWebTools({ + sourceConfig, + resolvedConfig, + context, + }); + } catch (error) { + if (params.mode === "strict") { + throw error; + } + localResolutionDiagnostics.push( + `${params.commandName}: failed to resolve web tool secrets locally (${describeUnknownError(error)}).`, + ); + } + } const inactiveRefPaths = new Set( context.warnings .filter((warning) => warning.code === "SECRETS_REF_IGNORED_INACTIVE_SURFACE") + .filter((warning) => !params.allowedPaths || params.allowedPaths.has(warning.path)) .map((warning) => warning.path), ); + const inactiveWarningDiagnostics = context.warnings + .filter((warning) => warning.code === "SECRETS_REF_IGNORED_INACTIVE_SURFACE") + .filter((warning) => !params.allowedPaths || params.allowedPaths.has(warning.path)) + .map((warning) => warning.message); const activePaths = new Set(context.assignments.map((assignment) => assignment.path)); - const localResolutionDiagnostics: string[] = []; for (const target of discoverConfigSecretTargetsByIds(sourceConfig, params.targetIds)) { if (params.allowedPaths && !params.allowedPaths.has(target.path)) { continue; @@ -244,6 +301,7 @@ async function resolveCommandSecretRefsLocally(params: { resolvedConfig, diagnostics: dedupeDiagnostics([ ...params.preflightDiagnostics, + ...inactiveWarningDiagnostics, ...filterInactiveSurfaceDiagnostics({ diagnostics: analyzed.diagnostics, inactiveRefPaths, diff --git a/src/cli/command-secret-targets.test.ts b/src/cli/command-secret-targets.test.ts index 3a7de543a02..a71ac5e00c4 100644 --- a/src/cli/command-secret-targets.test.ts +++ b/src/cli/command-secret-targets.test.ts @@ -9,6 +9,7 @@ describe("command secret target ids", () => { const ids = getAgentRuntimeCommandSecretTargetIds(); expect(ids.has("agents.defaults.memorySearch.remote.apiKey")).toBe(true); expect(ids.has("agents.list[].memorySearch.remote.apiKey")).toBe(true); + expect(ids.has("tools.web.fetch.firecrawl.apiKey")).toBe(true); }); it("keeps memory command target set focused on memorySearch remote credentials", () => { diff --git a/src/cli/command-secret-targets.ts b/src/cli/command-secret-targets.ts index c4a4fb5ea4a..e1c2c49e0ae 100644 --- a/src/cli/command-secret-targets.ts +++ b/src/cli/command-secret-targets.ts @@ -23,6 +23,7 @@ const COMMAND_SECRET_TARGETS = { "skills.entries.", "messages.tts.", "tools.web.search", + "tools.web.fetch.firecrawl.", ]), status: idsByPrefix([ "channels.", diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index 89775758411..e352f858c39 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -512,7 +512,7 @@ export type ToolsConfig = { /** Enable Firecrawl fallback (default: true when apiKey is set). */ enabled?: boolean; /** Firecrawl API key (optional; defaults to FIRECRAWL_API_KEY env var). */ - apiKey?: string; + apiKey?: SecretInput; /** Firecrawl base URL (default: https://api.firecrawl.dev). */ baseUrl?: string; /** Whether to keep only main content (default: true). */ diff --git a/src/gateway/server.reload.test.ts b/src/gateway/server.reload.test.ts index e691256d70f..b3a603fa287 100644 --- a/src/gateway/server.reload.test.ts +++ b/src/gateway/server.reload.test.ts @@ -175,12 +175,14 @@ describe("gateway hot reload", () => { let prevSkipGmail: string | undefined; let prevSkipProviders: string | undefined; let prevOpenAiApiKey: string | undefined; + let prevGeminiApiKey: string | undefined; beforeEach(() => { prevSkipChannels = process.env.OPENCLAW_SKIP_CHANNELS; prevSkipGmail = process.env.OPENCLAW_SKIP_GMAIL_WATCHER; prevSkipProviders = process.env.OPENCLAW_SKIP_PROVIDERS; prevOpenAiApiKey = process.env.OPENAI_API_KEY; + prevGeminiApiKey = process.env.GEMINI_API_KEY; process.env.OPENCLAW_SKIP_CHANNELS = "0"; delete process.env.OPENCLAW_SKIP_GMAIL_WATCHER; delete process.env.OPENCLAW_SKIP_PROVIDERS; @@ -207,6 +209,11 @@ describe("gateway hot reload", () => { } else { process.env.OPENAI_API_KEY = prevOpenAiApiKey; } + if (prevGeminiApiKey === undefined) { + delete process.env.GEMINI_API_KEY; + } else { + process.env.GEMINI_API_KEY = prevGeminiApiKey; + } }); async function writeEnvRefConfig() { @@ -328,6 +335,34 @@ describe("gateway hot reload", () => { ); } + async function writeWebSearchGeminiRefConfig() { + const configPath = process.env.OPENCLAW_CONFIG_PATH; + if (!configPath) { + throw new Error("OPENCLAW_CONFIG_PATH is not set"); + } + await fs.writeFile( + configPath, + `${JSON.stringify( + { + tools: { + web: { + search: { + enabled: true, + provider: "gemini", + gemini: { + apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" }, + }, + }, + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + } + async function removeMainAuthProfileStore() { const stateDir = process.env.OPENCLAW_STATE_DIR; if (!stateDir) { @@ -540,6 +575,64 @@ describe("gateway hot reload", () => { }); }); + it("emits one-shot degraded and recovered system events for web search secret reload transitions", async () => { + await writeWebSearchGeminiRefConfig(); + process.env.GEMINI_API_KEY = "gemini-startup-key"; // pragma: allowlist secret + + await withGatewayServer(async () => { + const onHotReload = hoisted.getOnHotReload(); + expect(onHotReload).toBeTypeOf("function"); + const sessionKey = resolveMainSessionKeyFromConfig(); + const plan = { + changedPaths: ["tools.web.search.gemini.apiKey"], + restartGateway: false, + restartReasons: [], + hotReasons: ["tools.web.search.gemini.apiKey"], + reloadHooks: false, + restartGmailWatcher: false, + restartBrowserControl: false, + restartCron: false, + restartHeartbeat: false, + restartChannels: new Set(), + noopPaths: [], + }; + const nextConfig = { + tools: { + web: { + search: { + enabled: true, + provider: "gemini", + gemini: { + apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" }, + }, + }, + }, + }, + }; + + delete process.env.GEMINI_API_KEY; + await expect(onHotReload?.(plan, nextConfig)).rejects.toThrow( + "[WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK]", + ); + const degradedEvents = drainSystemEvents(sessionKey); + expect(degradedEvents.some((event) => event.includes("[SECRETS_RELOADER_DEGRADED]"))).toBe( + true, + ); + + await expect(onHotReload?.(plan, nextConfig)).rejects.toThrow( + "[WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK]", + ); + expect(drainSystemEvents(sessionKey)).toEqual([]); + + process.env.GEMINI_API_KEY = "gemini-recovered-key"; // pragma: allowlist secret + await expect(onHotReload?.(plan, nextConfig)).resolves.toBeUndefined(); + const recoveredEvents = drainSystemEvents(sessionKey); + expect(recoveredEvents.some((event) => event.includes("[SECRETS_RELOADER_RECOVERED]"))).toBe( + true, + ); + }); + }); + it("serves secrets.reload immediately after startup without race failures", async () => { await writeEnvRefConfig(); process.env.OPENAI_API_KEY = "sk-startup"; // pragma: allowlist secret diff --git a/src/secrets/runtime-config-collectors-core.ts b/src/secrets/runtime-config-collectors-core.ts index 504331f0a96..99668371ad1 100644 --- a/src/secrets/runtime-config-collectors-core.ts +++ b/src/secrets/runtime-config-collectors-core.ts @@ -292,67 +292,6 @@ function collectMessagesTtsAssignments(params: { }); } -function collectToolsWebSearchAssignments(params: { - config: OpenClawConfig; - defaults: SecretDefaults | undefined; - context: ResolverContext; -}): void { - const tools = params.config.tools as Record | undefined; - if (!isRecord(tools) || !isRecord(tools.web) || !isRecord(tools.web.search)) { - return; - } - const search = tools.web.search; - const searchEnabled = search.enabled !== false; - const rawProvider = - typeof search.provider === "string" ? search.provider.trim().toLowerCase() : ""; - const selectedProvider = - rawProvider === "brave" || - rawProvider === "gemini" || - rawProvider === "grok" || - rawProvider === "kimi" || - rawProvider === "perplexity" - ? rawProvider - : undefined; - const paths = [ - "apiKey", - "gemini.apiKey", - "grok.apiKey", - "kimi.apiKey", - "perplexity.apiKey", - ] as const; - for (const path of paths) { - const [scope, field] = path.includes(".") ? path.split(".", 2) : [undefined, path]; - const target = scope ? search[scope] : search; - if (!isRecord(target)) { - continue; - } - const active = scope - ? searchEnabled && (selectedProvider === undefined || selectedProvider === scope) - : searchEnabled && (selectedProvider === undefined || selectedProvider === "brave"); - const inactiveReason = !searchEnabled - ? "tools.web.search is disabled." - : scope - ? selectedProvider === undefined - ? undefined - : `tools.web.search.provider is "${selectedProvider}".` - : selectedProvider === undefined - ? undefined - : `tools.web.search.provider is "${selectedProvider}".`; - collectSecretInputAssignment({ - value: target[field], - path: `tools.web.search.${path}`, - expected: "string", - defaults: params.defaults, - context: params.context, - active, - inactiveReason, - apply: (value) => { - target[field] = value; - }, - }); - } -} - function collectCronAssignments(params: { config: OpenClawConfig; defaults: SecretDefaults | undefined; @@ -401,6 +340,5 @@ export function collectCoreConfigAssignments(params: { collectTalkAssignments(params); collectGatewayAssignments(params); collectMessagesTtsAssignments(params); - collectToolsWebSearchAssignments(params); collectCronAssignments(params); } diff --git a/src/secrets/runtime-shared.ts b/src/secrets/runtime-shared.ts index 8374f642de8..77dcb3c051c 100644 --- a/src/secrets/runtime-shared.ts +++ b/src/secrets/runtime-shared.ts @@ -7,7 +7,12 @@ import { isRecord } from "./shared.js"; export type SecretResolverWarningCode = | "SECRETS_REF_OVERRIDES_PLAINTEXT" - | "SECRETS_REF_IGNORED_INACTIVE_SURFACE"; + | "SECRETS_REF_IGNORED_INACTIVE_SURFACE" + | "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT" + | "WEB_SEARCH_KEY_UNRESOLVED_FALLBACK_USED" + | "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK" + | "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_FALLBACK_USED" + | "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK"; export type SecretResolverWarning = { code: SecretResolverWarningCode; diff --git a/src/secrets/runtime-web-tools.test.ts b/src/secrets/runtime-web-tools.test.ts new file mode 100644 index 00000000000..b8c1e679ba6 --- /dev/null +++ b/src/secrets/runtime-web-tools.test.ts @@ -0,0 +1,451 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import * as secretResolve from "./resolve.js"; +import { createResolverContext } from "./runtime-shared.js"; +import { resolveRuntimeWebTools } from "./runtime-web-tools.js"; + +type ProviderUnderTest = "brave" | "gemini" | "grok" | "kimi" | "perplexity"; + +function asConfig(value: unknown): OpenClawConfig { + return value as OpenClawConfig; +} + +async function runRuntimeWebTools(params: { config: OpenClawConfig; env?: NodeJS.ProcessEnv }) { + const sourceConfig = structuredClone(params.config); + const resolvedConfig = structuredClone(params.config); + const context = createResolverContext({ + sourceConfig, + env: params.env ?? {}, + }); + const metadata = await resolveRuntimeWebTools({ + sourceConfig, + resolvedConfig, + context, + }); + return { metadata, resolvedConfig, context }; +} + +function createProviderSecretRefConfig( + provider: ProviderUnderTest, + envRefId: string, +): OpenClawConfig { + const search: Record = { + enabled: true, + provider, + }; + if (provider === "brave") { + search.apiKey = { source: "env", provider: "default", id: envRefId }; + } else { + search[provider] = { + apiKey: { source: "env", provider: "default", id: envRefId }, + }; + } + return asConfig({ + tools: { + web: { + search, + }, + }, + }); +} + +function readProviderKey(config: OpenClawConfig, provider: ProviderUnderTest): unknown { + if (provider === "brave") { + return config.tools?.web?.search?.apiKey; + } + if (provider === "gemini") { + return config.tools?.web?.search?.gemini?.apiKey; + } + if (provider === "grok") { + return config.tools?.web?.search?.grok?.apiKey; + } + if (provider === "kimi") { + return config.tools?.web?.search?.kimi?.apiKey; + } + return config.tools?.web?.search?.perplexity?.apiKey; +} + +describe("runtime web tools resolution", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.each([ + { + provider: "brave" as const, + envRefId: "BRAVE_PROVIDER_REF", + resolvedKey: "brave-provider-key", + }, + { + provider: "gemini" as const, + envRefId: "GEMINI_PROVIDER_REF", + resolvedKey: "gemini-provider-key", + }, + { + provider: "grok" as const, + envRefId: "GROK_PROVIDER_REF", + resolvedKey: "grok-provider-key", + }, + { + provider: "kimi" as const, + envRefId: "KIMI_PROVIDER_REF", + resolvedKey: "kimi-provider-key", + }, + { + provider: "perplexity" as const, + envRefId: "PERPLEXITY_PROVIDER_REF", + resolvedKey: "pplx-provider-key", + }, + ])( + "resolves configured provider SecretRef for $provider", + async ({ provider, envRefId, resolvedKey }) => { + const { metadata, resolvedConfig, context } = await runRuntimeWebTools({ + config: createProviderSecretRefConfig(provider, envRefId), + env: { + [envRefId]: resolvedKey, + }, + }); + + expect(metadata.search.providerConfigured).toBe(provider); + expect(metadata.search.providerSource).toBe("configured"); + expect(metadata.search.selectedProvider).toBe(provider); + expect(metadata.search.selectedProviderKeySource).toBe("secretRef"); + expect(readProviderKey(resolvedConfig, provider)).toBe(resolvedKey); + expect(context.warnings.map((warning) => warning.code)).not.toContain( + "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK", + ); + if (provider === "perplexity") { + expect(metadata.search.perplexityTransport).toBe("search_api"); + } + }, + ); + + it("auto-detects provider precedence across all configured providers", async () => { + const { metadata, resolvedConfig, context } = await runRuntimeWebTools({ + config: asConfig({ + tools: { + web: { + search: { + apiKey: { source: "env", provider: "default", id: "BRAVE_REF" }, + gemini: { + apiKey: { source: "env", provider: "default", id: "GEMINI_REF" }, + }, + grok: { + apiKey: { source: "env", provider: "default", id: "GROK_REF" }, + }, + kimi: { + apiKey: { source: "env", provider: "default", id: "KIMI_REF" }, + }, + perplexity: { + apiKey: { source: "env", provider: "default", id: "PERPLEXITY_REF" }, + }, + }, + }, + }, + }), + env: { + BRAVE_REF: "brave-precedence-key", + GEMINI_REF: "gemini-precedence-key", + GROK_REF: "grok-precedence-key", + KIMI_REF: "kimi-precedence-key", + PERPLEXITY_REF: "pplx-precedence-key", + }, + }); + + expect(metadata.search.providerSource).toBe("auto-detect"); + expect(metadata.search.selectedProvider).toBe("brave"); + expect(resolvedConfig.tools?.web?.search?.apiKey).toBe("brave-precedence-key"); + expect(context.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: "tools.web.search.gemini.apiKey" }), + expect.objectContaining({ path: "tools.web.search.grok.apiKey" }), + expect.objectContaining({ path: "tools.web.search.kimi.apiKey" }), + expect.objectContaining({ path: "tools.web.search.perplexity.apiKey" }), + ]), + ); + }); + + it("auto-detects first available provider and keeps lower-priority refs inactive", async () => { + const { metadata, resolvedConfig, context } = await runRuntimeWebTools({ + config: asConfig({ + tools: { + web: { + search: { + apiKey: { source: "env", provider: "default", id: "BRAVE_API_KEY_REF" }, + gemini: { + apiKey: { + source: "env", + provider: "default", + id: "MISSING_GEMINI_API_KEY_REF", + }, + }, + }, + }, + }, + }), + env: { + BRAVE_API_KEY_REF: "brave-runtime-key", + }, + }); + + expect(metadata.search.providerSource).toBe("auto-detect"); + expect(metadata.search.selectedProvider).toBe("brave"); + expect(metadata.search.selectedProviderKeySource).toBe("secretRef"); + expect(resolvedConfig.tools?.web?.search?.apiKey).toBe("brave-runtime-key"); + expect(resolvedConfig.tools?.web?.search?.gemini?.apiKey).toEqual({ + source: "env", + provider: "default", + id: "MISSING_GEMINI_API_KEY_REF", + }); + expect(context.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE", + path: "tools.web.search.gemini.apiKey", + }), + ]), + ); + expect(context.warnings.map((warning) => warning.code)).not.toContain( + "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK", + ); + }); + + it("auto-detects the next provider when a higher-priority ref is unresolved", async () => { + const { metadata, resolvedConfig, context } = await runRuntimeWebTools({ + config: asConfig({ + tools: { + web: { + search: { + apiKey: { source: "env", provider: "default", id: "MISSING_BRAVE_API_KEY_REF" }, + gemini: { + apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY_REF" }, + }, + }, + }, + }, + }), + env: { + GEMINI_API_KEY_REF: "gemini-runtime-key", + }, + }); + + expect(metadata.search.providerSource).toBe("auto-detect"); + expect(metadata.search.selectedProvider).toBe("gemini"); + expect(resolvedConfig.tools?.web?.search?.gemini?.apiKey).toBe("gemini-runtime-key"); + expect(context.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE", + path: "tools.web.search.apiKey", + }), + ]), + ); + expect(context.warnings.map((warning) => warning.code)).not.toContain( + "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK", + ); + }); + + it("warns when provider is invalid and falls back to auto-detect", async () => { + const { metadata, resolvedConfig, context } = await runRuntimeWebTools({ + config: asConfig({ + tools: { + web: { + search: { + provider: "invalid-provider", + gemini: { + apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY_REF" }, + }, + }, + }, + }, + }), + env: { + GEMINI_API_KEY_REF: "gemini-runtime-key", + }, + }); + + expect(metadata.search.providerConfigured).toBeUndefined(); + expect(metadata.search.providerSource).toBe("auto-detect"); + expect(metadata.search.selectedProvider).toBe("gemini"); + expect(resolvedConfig.tools?.web?.search?.gemini?.apiKey).toBe("gemini-runtime-key"); + expect(metadata.search.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT", + path: "tools.web.search.provider", + }), + ]), + ); + expect(context.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT", + path: "tools.web.search.provider", + }), + ]), + ); + }); + + it("fails fast when configured provider ref is unresolved with no fallback", async () => { + const sourceConfig = asConfig({ + tools: { + web: { + search: { + provider: "gemini", + gemini: { + apiKey: { source: "env", provider: "default", id: "MISSING_GEMINI_API_KEY_REF" }, + }, + }, + }, + }, + }); + const resolvedConfig = structuredClone(sourceConfig); + const context = createResolverContext({ + sourceConfig, + env: {}, + }); + + await expect( + resolveRuntimeWebTools({ + sourceConfig, + resolvedConfig, + context, + }), + ).rejects.toThrow("[WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK]"); + expect(context.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK", + path: "tools.web.search.gemini.apiKey", + }), + ]), + ); + }); + + it("does not resolve Firecrawl SecretRef when Firecrawl is inactive", async () => { + const resolveSpy = vi.spyOn(secretResolve, "resolveSecretRefValues"); + const { metadata, context } = await runRuntimeWebTools({ + config: asConfig({ + tools: { + web: { + fetch: { + enabled: false, + firecrawl: { + apiKey: { source: "env", provider: "default", id: "MISSING_FIRECRAWL_REF" }, + }, + }, + }, + }, + }), + }); + + expect(resolveSpy).not.toHaveBeenCalled(); + expect(metadata.fetch.firecrawl.active).toBe(false); + expect(metadata.fetch.firecrawl.apiKeySource).toBe("secretRef"); + expect(context.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE", + path: "tools.web.fetch.firecrawl.apiKey", + }), + ]), + ); + }); + + it("does not resolve Firecrawl SecretRef when Firecrawl is disabled", async () => { + const resolveSpy = vi.spyOn(secretResolve, "resolveSecretRefValues"); + const { metadata, context } = await runRuntimeWebTools({ + config: asConfig({ + tools: { + web: { + fetch: { + enabled: true, + firecrawl: { + enabled: false, + apiKey: { source: "env", provider: "default", id: "MISSING_FIRECRAWL_REF" }, + }, + }, + }, + }, + }), + }); + + expect(resolveSpy).not.toHaveBeenCalled(); + expect(metadata.fetch.firecrawl.active).toBe(false); + expect(metadata.fetch.firecrawl.apiKeySource).toBe("secretRef"); + expect(context.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE", + path: "tools.web.fetch.firecrawl.apiKey", + }), + ]), + ); + }); + + it("uses env fallback for unresolved Firecrawl SecretRef when active", async () => { + const { metadata, resolvedConfig, context } = await runRuntimeWebTools({ + config: asConfig({ + tools: { + web: { + fetch: { + firecrawl: { + apiKey: { source: "env", provider: "default", id: "MISSING_FIRECRAWL_REF" }, + }, + }, + }, + }, + }), + env: { + FIRECRAWL_API_KEY: "firecrawl-fallback-key", + }, + }); + + expect(metadata.fetch.firecrawl.active).toBe(true); + expect(metadata.fetch.firecrawl.apiKeySource).toBe("env"); + expect(resolvedConfig.tools?.web?.fetch?.firecrawl?.apiKey).toBe("firecrawl-fallback-key"); + expect(context.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_FALLBACK_USED", + path: "tools.web.fetch.firecrawl.apiKey", + }), + ]), + ); + }); + + it("fails fast when active Firecrawl SecretRef is unresolved with no fallback", async () => { + const sourceConfig = asConfig({ + tools: { + web: { + fetch: { + firecrawl: { + apiKey: { source: "env", provider: "default", id: "MISSING_FIRECRAWL_REF" }, + }, + }, + }, + }, + }); + const resolvedConfig = structuredClone(sourceConfig); + const context = createResolverContext({ + sourceConfig, + env: {}, + }); + + await expect( + resolveRuntimeWebTools({ + sourceConfig, + resolvedConfig, + context, + }), + ).rejects.toThrow("[WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK]"); + expect(context.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK", + path: "tools.web.fetch.firecrawl.apiKey", + }), + ]), + ); + }); +}); diff --git a/src/secrets/runtime-web-tools.ts b/src/secrets/runtime-web-tools.ts new file mode 100644 index 00000000000..004af2bdfe2 --- /dev/null +++ b/src/secrets/runtime-web-tools.ts @@ -0,0 +1,705 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { resolveSecretInputRef } from "../config/types.secrets.js"; +import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; +import { secretRefKey } from "./ref-contract.js"; +import { resolveSecretRefValues } from "./resolve.js"; +import { + pushInactiveSurfaceWarning, + pushWarning, + type ResolverContext, + type SecretDefaults, +} from "./runtime-shared.js"; + +const WEB_SEARCH_PROVIDERS = ["brave", "gemini", "grok", "kimi", "perplexity"] as const; +const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai"; +const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1"; +const PERPLEXITY_KEY_PREFIXES = ["pplx-"]; +const OPENROUTER_KEY_PREFIXES = ["sk-or-"]; + +type WebSearchProvider = (typeof WEB_SEARCH_PROVIDERS)[number]; + +type SecretResolutionSource = "config" | "secretRef" | "env" | "missing"; +type RuntimeWebProviderSource = "configured" | "auto-detect" | "none"; + +export type RuntimeWebDiagnosticCode = + | "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT" + | "WEB_SEARCH_AUTODETECT_SELECTED" + | "WEB_SEARCH_KEY_UNRESOLVED_FALLBACK_USED" + | "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK" + | "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_FALLBACK_USED" + | "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK"; + +export type RuntimeWebDiagnostic = { + code: RuntimeWebDiagnosticCode; + message: string; + path?: string; +}; + +export type RuntimeWebSearchMetadata = { + providerConfigured?: WebSearchProvider; + providerSource: RuntimeWebProviderSource; + selectedProvider?: WebSearchProvider; + selectedProviderKeySource?: SecretResolutionSource; + perplexityTransport?: "search_api" | "chat_completions"; + diagnostics: RuntimeWebDiagnostic[]; +}; + +export type RuntimeWebFetchFirecrawlMetadata = { + active: boolean; + apiKeySource: SecretResolutionSource; + diagnostics: RuntimeWebDiagnostic[]; +}; + +export type RuntimeWebToolsMetadata = { + search: RuntimeWebSearchMetadata; + fetch: { + firecrawl: RuntimeWebFetchFirecrawlMetadata; + }; + diagnostics: RuntimeWebDiagnostic[]; +}; + +type FetchConfig = NonNullable["web"] extends infer Web + ? Web extends { fetch?: infer Fetch } + ? Fetch + : undefined + : undefined; + +type SecretResolutionResult = { + value?: string; + source: SecretResolutionSource; + secretRefConfigured: boolean; + unresolvedRefReason?: string; + fallbackEnvVar?: string; + fallbackUsedAfterRefFailure: boolean; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeProvider(value: unknown): WebSearchProvider | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = value.trim().toLowerCase(); + if ( + normalized === "brave" || + normalized === "gemini" || + normalized === "grok" || + normalized === "kimi" || + normalized === "perplexity" + ) { + return normalized; + } + return undefined; +} + +function readNonEmptyEnvValue( + env: NodeJS.ProcessEnv, + names: string[], +): { value?: string; envVar?: string } { + for (const envVar of names) { + const value = normalizeSecretInput(env[envVar]); + if (value) { + return { value, envVar }; + } + } + return {}; +} + +function buildUnresolvedReason(params: { + path: string; + kind: "unresolved" | "non-string" | "empty"; + refLabel: string; +}): string { + if (params.kind === "non-string") { + return `${params.path} SecretRef resolved to a non-string value.`; + } + if (params.kind === "empty") { + return `${params.path} SecretRef resolved to an empty value.`; + } + return `${params.path} SecretRef is unresolved (${params.refLabel}).`; +} + +async function resolveSecretInputWithEnvFallback(params: { + sourceConfig: OpenClawConfig; + context: ResolverContext; + defaults: SecretDefaults | undefined; + value: unknown; + path: string; + envVars: string[]; +}): Promise { + const { ref } = resolveSecretInputRef({ + value: params.value, + defaults: params.defaults, + }); + + if (!ref) { + const configValue = normalizeSecretInput(params.value); + if (configValue) { + return { + value: configValue, + source: "config", + secretRefConfigured: false, + fallbackUsedAfterRefFailure: false, + }; + } + const fallback = readNonEmptyEnvValue(params.context.env, params.envVars); + if (fallback.value) { + return { + value: fallback.value, + source: "env", + fallbackEnvVar: fallback.envVar, + secretRefConfigured: false, + fallbackUsedAfterRefFailure: false, + }; + } + return { + source: "missing", + secretRefConfigured: false, + fallbackUsedAfterRefFailure: false, + }; + } + + const refLabel = `${ref.source}:${ref.provider}:${ref.id}`; + let resolvedFromRef: string | undefined; + let unresolvedRefReason: string | undefined; + + try { + const resolved = await resolveSecretRefValues([ref], { + config: params.sourceConfig, + env: params.context.env, + cache: params.context.cache, + }); + const resolvedValue = resolved.get(secretRefKey(ref)); + if (typeof resolvedValue !== "string") { + unresolvedRefReason = buildUnresolvedReason({ + path: params.path, + kind: "non-string", + refLabel, + }); + } else { + resolvedFromRef = normalizeSecretInput(resolvedValue); + if (!resolvedFromRef) { + unresolvedRefReason = buildUnresolvedReason({ + path: params.path, + kind: "empty", + refLabel, + }); + } + } + } catch { + unresolvedRefReason = buildUnresolvedReason({ + path: params.path, + kind: "unresolved", + refLabel, + }); + } + + if (resolvedFromRef) { + return { + value: resolvedFromRef, + source: "secretRef", + secretRefConfigured: true, + fallbackUsedAfterRefFailure: false, + }; + } + + const fallback = readNonEmptyEnvValue(params.context.env, params.envVars); + if (fallback.value) { + return { + value: fallback.value, + source: "env", + fallbackEnvVar: fallback.envVar, + unresolvedRefReason, + secretRefConfigured: true, + fallbackUsedAfterRefFailure: true, + }; + } + + return { + source: "missing", + unresolvedRefReason, + secretRefConfigured: true, + fallbackUsedAfterRefFailure: false, + }; +} + +function inferPerplexityBaseUrlFromApiKey(apiKey?: string): "direct" | "openrouter" | undefined { + if (!apiKey) { + return undefined; + } + const normalized = apiKey.toLowerCase(); + if (PERPLEXITY_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { + return "direct"; + } + if (OPENROUTER_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { + return "openrouter"; + } + return undefined; +} + +function resolvePerplexityRuntimeTransport(params: { + keyValue?: string; + keySource: SecretResolutionSource; + fallbackEnvVar?: string; + configValue: unknown; +}): "search_api" | "chat_completions" | undefined { + const config = isRecord(params.configValue) ? params.configValue : undefined; + const configuredBaseUrl = typeof config?.baseUrl === "string" ? config.baseUrl.trim() : ""; + const configuredModel = typeof config?.model === "string" ? config.model.trim() : ""; + + const baseUrl = (() => { + if (configuredBaseUrl) { + return configuredBaseUrl; + } + if (params.keySource === "env") { + if (params.fallbackEnvVar === "PERPLEXITY_API_KEY") { + return PERPLEXITY_DIRECT_BASE_URL; + } + if (params.fallbackEnvVar === "OPENROUTER_API_KEY") { + return DEFAULT_PERPLEXITY_BASE_URL; + } + } + if ((params.keySource === "config" || params.keySource === "secretRef") && params.keyValue) { + const inferred = inferPerplexityBaseUrlFromApiKey(params.keyValue); + return inferred === "openrouter" ? DEFAULT_PERPLEXITY_BASE_URL : PERPLEXITY_DIRECT_BASE_URL; + } + return DEFAULT_PERPLEXITY_BASE_URL; + })(); + + const hasLegacyOverride = Boolean(configuredBaseUrl || configuredModel); + const direct = (() => { + try { + return new URL(baseUrl).hostname.toLowerCase() === "api.perplexity.ai"; + } catch { + return false; + } + })(); + return hasLegacyOverride || !direct ? "chat_completions" : "search_api"; +} + +function ensureObject(target: Record, key: string): Record { + const current = target[key]; + if (isRecord(current)) { + return current; + } + const next: Record = {}; + target[key] = next; + return next; +} + +function setResolvedWebSearchApiKey(params: { + resolvedConfig: OpenClawConfig; + provider: WebSearchProvider; + value: string; +}): void { + const tools = ensureObject(params.resolvedConfig as Record, "tools"); + const web = ensureObject(tools, "web"); + const search = ensureObject(web, "search"); + if (params.provider === "brave") { + search.apiKey = params.value; + return; + } + const providerConfig = ensureObject(search, params.provider); + providerConfig.apiKey = params.value; +} + +function setResolvedFirecrawlApiKey(params: { + resolvedConfig: OpenClawConfig; + value: string; +}): void { + const tools = ensureObject(params.resolvedConfig as Record, "tools"); + const web = ensureObject(tools, "web"); + const fetch = ensureObject(web, "fetch"); + const firecrawl = ensureObject(fetch, "firecrawl"); + firecrawl.apiKey = params.value; +} + +function envVarsForProvider(provider: WebSearchProvider): string[] { + if (provider === "brave") { + return ["BRAVE_API_KEY"]; + } + if (provider === "gemini") { + return ["GEMINI_API_KEY"]; + } + if (provider === "grok") { + return ["XAI_API_KEY"]; + } + if (provider === "kimi") { + return ["KIMI_API_KEY", "MOONSHOT_API_KEY"]; + } + return ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"]; +} + +function resolveProviderKeyValue( + search: Record, + provider: WebSearchProvider, +): unknown { + if (provider === "brave") { + return search.apiKey; + } + const scoped = search[provider]; + if (!isRecord(scoped)) { + return undefined; + } + return scoped.apiKey; +} + +function hasConfiguredSecretRef(value: unknown, defaults: SecretDefaults | undefined): boolean { + return Boolean( + resolveSecretInputRef({ + value, + defaults, + }).ref, + ); +} + +export async function resolveRuntimeWebTools(params: { + sourceConfig: OpenClawConfig; + resolvedConfig: OpenClawConfig; + context: ResolverContext; +}): Promise { + const defaults = params.sourceConfig.secrets?.defaults; + const diagnostics: RuntimeWebDiagnostic[] = []; + + const tools = isRecord(params.sourceConfig.tools) ? params.sourceConfig.tools : undefined; + const web = isRecord(tools?.web) ? tools.web : undefined; + const search = isRecord(web?.search) ? web.search : undefined; + + const searchMetadata: RuntimeWebSearchMetadata = { + providerSource: "none", + diagnostics: [], + }; + + const searchEnabled = search?.enabled !== false; + const rawProvider = + typeof search?.provider === "string" ? search.provider.trim().toLowerCase() : ""; + const configuredProvider = normalizeProvider(rawProvider); + + if (rawProvider && !configuredProvider) { + const diagnostic: RuntimeWebDiagnostic = { + code: "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT", + message: `tools.web.search.provider is "${rawProvider}". Falling back to auto-detect precedence.`, + path: "tools.web.search.provider", + }; + diagnostics.push(diagnostic); + searchMetadata.diagnostics.push(diagnostic); + pushWarning(params.context, { + code: "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT", + path: "tools.web.search.provider", + message: diagnostic.message, + }); + } + + if (configuredProvider) { + searchMetadata.providerConfigured = configuredProvider; + searchMetadata.providerSource = "configured"; + } + + if (searchEnabled && search) { + const candidates = configuredProvider ? [configuredProvider] : [...WEB_SEARCH_PROVIDERS]; + const unresolvedWithoutFallback: Array<{ + provider: WebSearchProvider; + path: string; + reason: string; + }> = []; + + let selectedProvider: WebSearchProvider | undefined; + let selectedResolution: SecretResolutionResult | undefined; + + for (const provider of candidates) { + const path = + provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`; + const value = resolveProviderKeyValue(search, provider); + const resolution = await resolveSecretInputWithEnvFallback({ + sourceConfig: params.sourceConfig, + context: params.context, + defaults, + value, + path, + envVars: envVarsForProvider(provider), + }); + + if (resolution.secretRefConfigured && resolution.fallbackUsedAfterRefFailure) { + const diagnostic: RuntimeWebDiagnostic = { + code: "WEB_SEARCH_KEY_UNRESOLVED_FALLBACK_USED", + message: + `${path} SecretRef could not be resolved; using ${resolution.fallbackEnvVar ?? "env fallback"}. ` + + (resolution.unresolvedRefReason ?? "").trim(), + path, + }; + diagnostics.push(diagnostic); + searchMetadata.diagnostics.push(diagnostic); + pushWarning(params.context, { + code: "WEB_SEARCH_KEY_UNRESOLVED_FALLBACK_USED", + path, + message: diagnostic.message, + }); + } + + if (resolution.secretRefConfigured && !resolution.value && resolution.unresolvedRefReason) { + unresolvedWithoutFallback.push({ + provider, + path, + reason: resolution.unresolvedRefReason, + }); + } + + if (configuredProvider) { + selectedProvider = provider; + selectedResolution = resolution; + if (resolution.value) { + setResolvedWebSearchApiKey({ + resolvedConfig: params.resolvedConfig, + provider, + value: resolution.value, + }); + } + break; + } + + if (resolution.value) { + selectedProvider = provider; + selectedResolution = resolution; + setResolvedWebSearchApiKey({ + resolvedConfig: params.resolvedConfig, + provider, + value: resolution.value, + }); + break; + } + } + + if (configuredProvider) { + const unresolved = unresolvedWithoutFallback[0]; + if (unresolved) { + const diagnostic: RuntimeWebDiagnostic = { + code: "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK", + message: unresolved.reason, + path: unresolved.path, + }; + diagnostics.push(diagnostic); + searchMetadata.diagnostics.push(diagnostic); + pushWarning(params.context, { + code: "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK", + path: unresolved.path, + message: unresolved.reason, + }); + throw new Error(`[WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK] ${unresolved.reason}`); + } + } else { + if (!selectedProvider && unresolvedWithoutFallback.length > 0) { + const unresolved = unresolvedWithoutFallback[0]; + const diagnostic: RuntimeWebDiagnostic = { + code: "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK", + message: unresolved.reason, + path: unresolved.path, + }; + diagnostics.push(diagnostic); + searchMetadata.diagnostics.push(diagnostic); + pushWarning(params.context, { + code: "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK", + path: unresolved.path, + message: unresolved.reason, + }); + throw new Error(`[WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK] ${unresolved.reason}`); + } + + if (selectedProvider) { + const diagnostic: RuntimeWebDiagnostic = { + code: "WEB_SEARCH_AUTODETECT_SELECTED", + message: `tools.web.search auto-detected provider "${selectedProvider}" from available credentials.`, + path: "tools.web.search.provider", + }; + diagnostics.push(diagnostic); + searchMetadata.diagnostics.push(diagnostic); + } + } + + if (selectedProvider) { + searchMetadata.selectedProvider = selectedProvider; + searchMetadata.selectedProviderKeySource = selectedResolution?.source; + if (!configuredProvider) { + searchMetadata.providerSource = "auto-detect"; + } + if (selectedProvider === "perplexity") { + searchMetadata.perplexityTransport = resolvePerplexityRuntimeTransport({ + keyValue: selectedResolution?.value, + keySource: selectedResolution?.source ?? "missing", + fallbackEnvVar: selectedResolution?.fallbackEnvVar, + configValue: search.perplexity, + }); + } + } + } + + if (searchEnabled && search && !configuredProvider && searchMetadata.selectedProvider) { + for (const provider of WEB_SEARCH_PROVIDERS) { + if (provider === searchMetadata.selectedProvider) { + continue; + } + const path = + provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`; + const value = resolveProviderKeyValue(search, provider); + if (!hasConfiguredSecretRef(value, defaults)) { + continue; + } + pushInactiveSurfaceWarning({ + context: params.context, + path, + details: `tools.web.search auto-detected provider is "${searchMetadata.selectedProvider}".`, + }); + } + } else if (search && !searchEnabled) { + for (const provider of WEB_SEARCH_PROVIDERS) { + const path = + provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`; + const value = resolveProviderKeyValue(search, provider); + if (!hasConfiguredSecretRef(value, defaults)) { + continue; + } + pushInactiveSurfaceWarning({ + context: params.context, + path, + details: "tools.web.search is disabled.", + }); + } + } + + if (searchEnabled && search && configuredProvider) { + for (const provider of WEB_SEARCH_PROVIDERS) { + if (provider === configuredProvider) { + continue; + } + const path = + provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`; + const value = resolveProviderKeyValue(search, provider); + if (!hasConfiguredSecretRef(value, defaults)) { + continue; + } + pushInactiveSurfaceWarning({ + context: params.context, + path, + details: `tools.web.search.provider is "${configuredProvider}".`, + }); + } + } + + const fetch = isRecord(web?.fetch) ? (web.fetch as FetchConfig) : undefined; + const firecrawl = isRecord(fetch?.firecrawl) ? fetch.firecrawl : undefined; + const fetchEnabled = fetch?.enabled !== false; + const firecrawlEnabled = firecrawl?.enabled !== false; + const firecrawlActive = Boolean(fetchEnabled && firecrawlEnabled); + const firecrawlPath = "tools.web.fetch.firecrawl.apiKey"; + let firecrawlResolution: SecretResolutionResult = { + source: "missing", + secretRefConfigured: false, + fallbackUsedAfterRefFailure: false, + }; + + const firecrawlDiagnostics: RuntimeWebDiagnostic[] = []; + + if (firecrawlActive) { + firecrawlResolution = await resolveSecretInputWithEnvFallback({ + sourceConfig: params.sourceConfig, + context: params.context, + defaults, + value: firecrawl?.apiKey, + path: firecrawlPath, + envVars: ["FIRECRAWL_API_KEY"], + }); + + if (firecrawlResolution.value) { + setResolvedFirecrawlApiKey({ + resolvedConfig: params.resolvedConfig, + value: firecrawlResolution.value, + }); + } + + if (firecrawlResolution.secretRefConfigured) { + if (firecrawlResolution.fallbackUsedAfterRefFailure) { + const diagnostic: RuntimeWebDiagnostic = { + code: "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_FALLBACK_USED", + message: + `${firecrawlPath} SecretRef could not be resolved; using ${firecrawlResolution.fallbackEnvVar ?? "env fallback"}. ` + + (firecrawlResolution.unresolvedRefReason ?? "").trim(), + path: firecrawlPath, + }; + diagnostics.push(diagnostic); + firecrawlDiagnostics.push(diagnostic); + pushWarning(params.context, { + code: "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_FALLBACK_USED", + path: firecrawlPath, + message: diagnostic.message, + }); + } + + if (!firecrawlResolution.value && firecrawlResolution.unresolvedRefReason) { + const diagnostic: RuntimeWebDiagnostic = { + code: "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK", + message: firecrawlResolution.unresolvedRefReason, + path: firecrawlPath, + }; + diagnostics.push(diagnostic); + firecrawlDiagnostics.push(diagnostic); + pushWarning(params.context, { + code: "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK", + path: firecrawlPath, + message: firecrawlResolution.unresolvedRefReason, + }); + throw new Error( + `[WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK] ${firecrawlResolution.unresolvedRefReason}`, + ); + } + } + } else { + if (hasConfiguredSecretRef(firecrawl?.apiKey, defaults)) { + pushInactiveSurfaceWarning({ + context: params.context, + path: firecrawlPath, + details: !fetchEnabled + ? "tools.web.fetch is disabled." + : "tools.web.fetch.firecrawl.enabled is false.", + }); + firecrawlResolution = { + source: "secretRef", + secretRefConfigured: true, + fallbackUsedAfterRefFailure: false, + }; + } else { + const configuredInlineValue = normalizeSecretInput(firecrawl?.apiKey); + if (configuredInlineValue) { + firecrawlResolution = { + value: configuredInlineValue, + source: "config", + secretRefConfigured: false, + fallbackUsedAfterRefFailure: false, + }; + } else { + const envFallback = readNonEmptyEnvValue(params.context.env, ["FIRECRAWL_API_KEY"]); + if (envFallback.value) { + firecrawlResolution = { + value: envFallback.value, + source: "env", + fallbackEnvVar: envFallback.envVar, + secretRefConfigured: false, + fallbackUsedAfterRefFailure: false, + }; + } + } + } + } + + return { + search: searchMetadata, + fetch: { + firecrawl: { + active: firecrawlActive, + apiKeySource: firecrawlResolution.source, + diagnostics: firecrawlDiagnostics, + }, + }, + diagnostics, + }; +} diff --git a/src/secrets/runtime.test.ts b/src/secrets/runtime.test.ts index 463914bf899..f03ce73da3e 100644 --- a/src/secrets/runtime.test.ts +++ b/src/secrets/runtime.test.ts @@ -8,6 +8,7 @@ import { withTempHome } from "../config/home-env.test-harness.js"; import { activateSecretsRuntimeSnapshot, clearSecretsRuntimeSnapshot, + getActiveRuntimeWebToolsMetadata, getActiveSecretsRuntimeSnapshot, prepareSecretsRuntimeSnapshot, } from "./runtime.js"; @@ -342,7 +343,7 @@ describe("secrets runtime snapshot", () => { ); }); - it("resolves provider-specific refs in web search auto mode", async () => { + it("keeps non-selected provider refs inactive in web search auto mode", async () => { const snapshot = await prepareSecretsRuntimeSnapshot({ config: asConfig({ tools: { @@ -366,9 +367,19 @@ describe("secrets runtime snapshot", () => { }); expect(snapshot.config.tools?.web?.search?.apiKey).toBe("web-search-ref"); - expect(snapshot.config.tools?.web?.search?.gemini?.apiKey).toBe("web-search-gemini-ref"); - expect(snapshot.warnings.map((warning) => warning.path)).not.toContain( - "tools.web.search.gemini.apiKey", + expect(snapshot.config.tools?.web?.search?.gemini?.apiKey).toEqual({ + source: "env", + provider: "default", + id: "WEB_SEARCH_GEMINI_API_KEY", + }); + expect(snapshot.webTools.search.selectedProvider).toBe("brave"); + expect(snapshot.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE", + path: "tools.web.search.gemini.apiKey", + }), + ]), ); }); @@ -401,6 +412,71 @@ describe("secrets runtime snapshot", () => { ); }); + it("fails fast at startup when selected web search provider ref is unresolved", async () => { + await expect( + prepareSecretsRuntimeSnapshot({ + config: asConfig({ + tools: { + web: { + search: { + enabled: true, + provider: "gemini", + gemini: { + apiKey: { + source: "env", + provider: "default", + id: "MISSING_WEB_SEARCH_GEMINI_API_KEY", + }, + }, + }, + }, + }, + }), + env: {}, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => ({ version: 1, profiles: {} }), + }), + ).rejects.toThrow("[WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK]"); + }); + + it("exposes active runtime web tool metadata as a defensive clone", async () => { + const snapshot = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + tools: { + web: { + search: { + provider: "gemini", + gemini: { + apiKey: { source: "env", provider: "default", id: "WEB_SEARCH_GEMINI_API_KEY" }, + }, + }, + }, + }, + }), + env: { + WEB_SEARCH_GEMINI_API_KEY: "web-search-gemini-ref", // pragma: allowlist secret + }, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => ({ version: 1, profiles: {} }), + }); + + activateSecretsRuntimeSnapshot(snapshot); + + const first = getActiveRuntimeWebToolsMetadata(); + expect(first?.search.providerConfigured).toBe("gemini"); + expect(first?.search.selectedProvider).toBe("gemini"); + expect(first?.search.selectedProviderKeySource).toBe("secretRef"); + if (!first) { + throw new Error("missing runtime web tools metadata"); + } + first.search.providerConfigured = "brave"; + first.search.selectedProvider = "brave"; + + const second = getActiveRuntimeWebToolsMetadata(); + expect(second?.search.providerConfigured).toBe("gemini"); + expect(second?.search.selectedProvider).toBe("gemini"); + }); + it("resolves file refs via configured file provider", async () => { if (process.platform === "win32") { return; @@ -615,7 +691,7 @@ describe("secrets runtime snapshot", () => { }); }); - it("clears active secrets runtime state and throws when refresh fails after a write", async () => { + it("keeps last-known-good runtime snapshot active when refresh fails after a write", async () => { if (os.platform() === "win32") { return; } @@ -704,9 +780,11 @@ describe("secrets runtime snapshot", () => { /runtime snapshot refresh failed: simulated secrets runtime refresh failure/i, ); - expect(getActiveSecretsRuntimeSnapshot()).toBeNull(); - expect(loadConfig().gateway?.auth).toEqual({ mode: "token" }); - expect(loadConfig().models?.providers?.openai?.apiKey).toEqual({ + const activeAfterFailure = getActiveSecretsRuntimeSnapshot(); + expect(activeAfterFailure).not.toBeNull(); + expect(loadConfig().gateway?.auth).toBeUndefined(); + expect(loadConfig().models?.providers?.openai?.apiKey).toBe("sk-file-runtime"); + expect(activeAfterFailure?.sourceConfig.models?.providers?.openai?.apiKey).toEqual({ source: "file", provider: "default", id: "/providers/openai/apiKey", @@ -715,9 +793,75 @@ describe("secrets runtime snapshot", () => { const persistedStore = ensureAuthProfileStore(agentDir).profiles["openai:default"]; expect(persistedStore).toMatchObject({ type: "api_key", - keyRef: { source: "file", provider: "default", id: "/providers/openai/apiKey" }, + key: "sk-file-runtime", + }); + }); + }); + + it("keeps last-known-good web runtime snapshot when reload introduces unresolved active web refs", async () => { + await withTempHome("openclaw-secrets-runtime-web-reload-lkg-", async (home) => { + const prepared = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + tools: { + web: { + search: { + provider: "gemini", + gemini: { + apiKey: { source: "env", provider: "default", id: "WEB_SEARCH_GEMINI_API_KEY" }, + }, + }, + }, + }, + }), + env: { + WEB_SEARCH_GEMINI_API_KEY: "web-search-gemini-runtime-key", // pragma: allowlist secret + }, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => ({ version: 1, profiles: {} }), + }); + + activateSecretsRuntimeSnapshot(prepared); + + await expect( + writeConfigFile({ + ...loadConfig(), + tools: { + web: { + search: { + provider: "gemini", + gemini: { + apiKey: { + source: "env", + provider: "default", + id: "MISSING_WEB_SEARCH_GEMINI_API_KEY", + }, + }, + }, + }, + }, + }), + ).rejects.toThrow( + /runtime snapshot refresh failed: .*WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK/i, + ); + + const activeAfterFailure = getActiveSecretsRuntimeSnapshot(); + expect(activeAfterFailure).not.toBeNull(); + expect(loadConfig().tools?.web?.search?.gemini?.apiKey).toBe("web-search-gemini-runtime-key"); + expect(activeAfterFailure?.sourceConfig.tools?.web?.search?.gemini?.apiKey).toEqual({ + source: "env", + provider: "default", + id: "WEB_SEARCH_GEMINI_API_KEY", + }); + expect(getActiveRuntimeWebToolsMetadata()?.search.selectedProvider).toBe("gemini"); + + const persistedConfig = JSON.parse( + await fs.readFile(path.join(home, ".openclaw", "openclaw.json"), "utf8"), + ) as OpenClawConfig; + expect(persistedConfig.tools?.web?.search?.gemini?.apiKey).toEqual({ + source: "env", + provider: "default", + id: "MISSING_WEB_SEARCH_GEMINI_API_KEY", }); - expect("key" in persistedStore ? persistedStore.key : undefined).toBeUndefined(); }); }); diff --git a/src/secrets/runtime.ts b/src/secrets/runtime.ts index 9e69ffa60ad..903fe5a6d24 100644 --- a/src/secrets/runtime.ts +++ b/src/secrets/runtime.ts @@ -25,6 +25,7 @@ import { createResolverContext, type SecretResolverWarning, } from "./runtime-shared.js"; +import { resolveRuntimeWebTools, type RuntimeWebToolsMetadata } from "./runtime-web-tools.js"; export type { SecretResolverWarning } from "./runtime-shared.js"; @@ -33,6 +34,7 @@ export type PreparedSecretsRuntimeSnapshot = { config: OpenClawConfig; authStores: Array<{ agentDir: string; store: AuthProfileStore }>; warnings: SecretResolverWarning[]; + webTools: RuntimeWebToolsMetadata; }; type SecretsRuntimeRefreshContext = { @@ -57,6 +59,7 @@ function cloneSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): PreparedSecret store: structuredClone(entry.store), })), warnings: snapshot.warnings.map((warning) => ({ ...warning })), + webTools: structuredClone(snapshot.webTools), }; } @@ -148,6 +151,11 @@ export async function prepareSecretsRuntimeSnapshot(params: { config: resolvedConfig, authStores, warnings: context.warnings, + webTools: await resolveRuntimeWebTools({ + sourceConfig, + resolvedConfig, + context, + }), }; preparedSnapshotRefreshContext.set(snapshot, { env: { ...(params.env ?? process.env) } as Record, @@ -185,7 +193,6 @@ export function activateSecretsRuntimeSnapshot(snapshot: PreparedSecretsRuntimeS activateSecretsRuntimeSnapshot(refreshed); return true; }, - clearOnRefreshFailure: clearActiveSecretsRuntimeState, }); } @@ -200,6 +207,13 @@ export function getActiveSecretsRuntimeSnapshot(): PreparedSecretsRuntimeSnapsho return snapshot; } +export function getActiveRuntimeWebToolsMetadata(): RuntimeWebToolsMetadata | null { + if (!activeSnapshot) { + return null; + } + return structuredClone(activeSnapshot.webTools); +} + export function resolveCommandSecretsFromActiveRuntimeSnapshot(params: { commandName: string; targetIds: ReadonlySet; diff --git a/src/secrets/target-registry-data.ts b/src/secrets/target-registry-data.ts index 3be4992d28f..f085c9981ab 100644 --- a/src/secrets/target-registry-data.ts +++ b/src/secrets/target-registry-data.ts @@ -689,6 +689,17 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ includeInConfigure: true, includeInAudit: true, }, + { + id: "tools.web.fetch.firecrawl.apiKey", + targetType: "tools.web.fetch.firecrawl.apiKey", + configFile: "openclaw.json", + pathPattern: "tools.web.fetch.firecrawl.apiKey", + secretShape: SECRET_INPUT_SHAPE, + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, { id: "tools.web.search.apiKey", targetType: "tools.web.search.apiKey", From 705c6a422dfc75463cedc2f51d1a46cd2384d8b7 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:01:55 -0500 Subject: [PATCH 12/19] Add provider routing details to bug report form (#41712) --- .github/ISSUE_TEMPLATE/bug_report.yml | 31 +++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c45885b48b6..3be43c6740a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -76,6 +76,37 @@ body: label: Install method description: How OpenClaw was installed or launched. placeholder: npm global / pnpm dev / docker / mac app + - type: input + id: model + attributes: + label: Model + description: Effective model under test. + placeholder: minimax/text-01 / openrouter/anthropic/claude-opus-4.1 / anthropic/claude-sonnet-4.5 + validations: + required: true + - type: input + id: provider_chain + attributes: + label: Provider / routing chain + description: Effective request path through gateways, proxies, providers, or model routers. + placeholder: openclaw -> cloudflare-ai-gateway -> minimax + validations: + required: true + - type: input + id: config_location + attributes: + label: Config file / key location + description: Optional. Relevant config source or key path if this bug depends on overrides or custom provider setup. Redact secrets. + placeholder: ~/.openclaw/openclaw.json ; models.providers.cloudflare-ai-gateway.baseUrl ; ~/.openclaw/agents//agent/models.json + - type: textarea + id: provider_setup_details + attributes: + label: Additional provider/model setup details + description: Optional. Include redacted routing details, per-agent overrides, auth-profile interactions, env/config context, or anything else needed to explain the effective provider/model setup. Do not include API keys, tokens, or passwords. + placeholder: | + Default route is openclaw -> cloudflare-ai-gateway -> minimax. + Previous setup was openclaw -> cloudflare-ai-gateway -> openrouter -> minimax. + Relevant config lives in ~/.openclaw/openclaw.json under models.providers.minimax and models.providers.cloudflare-ai-gateway. - type: textarea id: logs attributes: From 989ee21b2414a574164d9871215cf32089edf7a7 Mon Sep 17 00:00:00 2001 From: Benji Peng <11394934+benjipeng@users.noreply.github.com> Date: Tue, 10 Mar 2026 00:14:07 -0400 Subject: [PATCH 13/19] ui: fix sessions table collapse on narrow widths (#12175) Merged via squash. Prepared head SHA: b1fcfba868fcfb7b9ee3496725921f3f38f58ac4 Co-authored-by: benjipeng <11394934+benjipeng@users.noreply.github.com> Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com> Reviewed-by: @BunsDev --- .pi/prompts/reviewpr.md | 15 +++++++-------- CHANGELOG.md | 1 + src/node-host/runner.credentials.test.ts | 3 +++ ui/src/styles/components.css | 15 +++++++++++++++ 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/.pi/prompts/reviewpr.md b/.pi/prompts/reviewpr.md index e3ebc0dd9c6..1b8a20dda90 100644 --- a/.pi/prompts/reviewpr.md +++ b/.pi/prompts/reviewpr.md @@ -12,7 +12,6 @@ Do (review-only) Goal: produce a thorough review and a clear recommendation (READY FOR /landpr vs NEEDS WORK vs INVALID CLAIM). Do NOT merge, do NOT push, do NOT make changes in the repo as part of this command. 0. Truthfulness + reality gate (required for bug-fix claims) - - Do not trust the issue text or PR summary by default; verify in code and evidence. - If the PR claims to fix a bug linked to an issue, confirm the bug exists now (repro steps, logs, failing test, or clear code-path proof). - Prove root cause with exact location (`path/file.ts:line` + explanation of why behavior is wrong). @@ -86,13 +85,13 @@ B) Claim verification matrix (required) - Fill this table: - | Field | Evidence | - |---|---| - | Claimed problem | ... | - | Evidence observed (repro/log/test/code) | ... | - | Root cause location (`path:line`) | ... | - | Why this fix addresses that root cause | ... | - | Regression coverage (test name or manual proof) | ... | + | Field | Evidence | + | ----------------------------------------------- | -------- | + | Claimed problem | ... | + | Evidence observed (repro/log/test/code) | ... | + | Root cause location (`path:line`) | ... | + | Why this fix addresses that root cause | ... | + | Regression coverage (test name or manual proof) | ... | - If any row is missing/weak, default to `NEEDS WORK` or `INVALID CLAIM`. diff --git a/CHANGELOG.md b/CHANGELOG.md index c19a5c2eda7..a786e384dc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ Docs: https://docs.openclaw.ai - 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. +- Control UI/Sessions: restore single-column session table collapse on narrow viewport or container widths by moving the responsive table override next to the base grid rule and enabling inline-size container queries. (#12175) Thanks @benjipeng. ## 2026.3.8 diff --git a/src/node-host/runner.credentials.test.ts b/src/node-host/runner.credentials.test.ts index 9c17c605421..6138a6b954e 100644 --- a/src/node-host/runner.credentials.test.ts +++ b/src/node-host/runner.credentials.test.ts @@ -76,6 +76,7 @@ describe("resolveNodeHostGatewayCredentials", () => { await withEnvAsync( { OPENCLAW_GATEWAY_TOKEN: undefined, + OPENCLAW_GATEWAY_PASSWORD: undefined, REMOTE_GATEWAY_TOKEN: "token-from-ref", }, async () => { @@ -91,6 +92,7 @@ describe("resolveNodeHostGatewayCredentials", () => { await withEnvAsync( { OPENCLAW_GATEWAY_TOKEN: "token-from-env", + OPENCLAW_GATEWAY_PASSWORD: undefined, REMOTE_GATEWAY_TOKEN: "token-from-ref", }, async () => { @@ -106,6 +108,7 @@ describe("resolveNodeHostGatewayCredentials", () => { await withEnvAsync( { OPENCLAW_GATEWAY_TOKEN: undefined, + OPENCLAW_GATEWAY_PASSWORD: undefined, MISSING_REMOTE_GATEWAY_TOKEN: undefined, }, async () => { diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index c7a6a425dc7..126972ca003 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -1425,6 +1425,7 @@ .table { display: grid; + container-type: inline-size; gap: 6px; } @@ -1455,6 +1456,20 @@ border-color: var(--border-strong); } +@media (max-width: 1100px) { + .table-head, + .table-row { + grid-template-columns: 1fr; + } +} + +@container (max-width: 1100px) { + .table-head, + .table-row { + grid-template-columns: 1fr; + } +} + .session-link { text-decoration: none; color: var(--accent); From 96e4975922de172ddac985fcd3bfdeaf13cc16ae Mon Sep 17 00:00:00 2001 From: Frank Yang Date: Tue, 10 Mar 2026 12:44:33 +0800 Subject: [PATCH 14/19] fix: protect bootstrap files during memory flush (#38574) Merged via squash. Prepared head SHA: a0b9a02e2ef1a6f5480621ccb799a8b35a10ce48 Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com> Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com> Reviewed-by: @frankekn --- CHANGELOG.md | 1 + src/agents/pi-embedded-runner/run/attempt.ts | 2 + src/agents/pi-embedded-runner/run/params.ts | 2 + src/agents/pi-tools.read.ts | 156 ++++++++++++++++++ src/agents/pi-tools.ts | 40 ++++- .../pi-tools.workspace-only-false.test.ts | 54 +++++- src/auto-reply/reply/agent-runner-memory.ts | 8 + .../agent-runner.runreplyagent.e2e.test.ts | 22 ++- src/auto-reply/reply/memory-flush.test.ts | 15 +- src/auto-reply/reply/memory-flush.ts | 57 ++++++- src/auto-reply/reply/reply-state.test.ts | 8 + src/infra/fs-safe.test.ts | 57 +++++++ src/infra/fs-safe.ts | 61 ++++++- 13 files changed, 468 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a786e384dc4..f017b834209 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -468,6 +468,7 @@ Docs: https://docs.openclaw.ai - Control UI/Telegram sender labels: preserve inbound sender labels in sanitized chat history so dashboard user-message groups split correctly and show real group-member names instead of `You`. (#39414) Thanks @obviyus. - Agents/failover 402 recovery: keep temporary spend-limit `402` payloads retryable, preserve explicit insufficient-credit billing detection even in long provider payloads, and allow throttled billing-cooldown probes so single-provider setups can recover instead of staying locked out. (#38533) Thanks @xialonglee. - Browser/config schema: accept `browser.profiles.*.driver: "openclaw"` while preserving legacy `"clawd"` compatibility in validated config. (#39374; based on #35621) Thanks @gambletan and @ingyukoh. +- Memory flush/bootstrap file protection: restrict memory-flush runs to append-only `read`/`write` tools and route host-side memory appends through root-enforced safe file handles so flush turns cannot overwrite bootstrap files via `exec` or unsafe raw rewrites. (#38574) Thanks @frankekn. ## 2026.3.2 diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 25f13c666c7..f6f18801497 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -870,6 +870,8 @@ export async function runEmbeddedAttempt( agentDir, workspaceDir: effectiveWorkspace, config: params.config, + trigger: params.trigger, + memoryFlushWritePath: params.memoryFlushWritePath, abortSignal: runAbortController.signal, modelProvider: params.model.provider, modelId: params.modelId, diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/pi-embedded-runner/run/params.ts index ee743d7a0c1..bf65515ce46 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -29,6 +29,8 @@ export type RunEmbeddedPiAgentParams = { agentAccountId?: string; /** What initiated this agent run: "user", "heartbeat", "cron", or "memory". */ trigger?: string; + /** Relative workspace path that memory-triggered writes are allowed to append to. */ + memoryFlushWritePath?: string; /** Delivery target (e.g. telegram:group:123:topic:456) for topic/thread routing. */ messageTo?: string; /** Thread/topic identifier for routing replies to the originating thread. */ diff --git a/src/agents/pi-tools.read.ts b/src/agents/pi-tools.read.ts index b01c7adff03..5ea48b01fa1 100644 --- a/src/agents/pi-tools.read.ts +++ b/src/agents/pi-tools.read.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import { createEditTool, createReadTool, createWriteTool } from "@mariozechner/pi-coding-agent"; import { + appendFileWithinRoot, SafeOpenError, openFileWithinRoot, readFileWithinRoot, @@ -406,6 +407,161 @@ function mapContainerPathToWorkspaceRoot(params: { return path.resolve(params.root, ...relative.split("/").filter(Boolean)); } +export function resolveToolPathAgainstWorkspaceRoot(params: { + filePath: string; + root: string; + containerWorkdir?: string; +}): string { + const mapped = mapContainerPathToWorkspaceRoot(params); + const candidate = mapped.startsWith("@") ? mapped.slice(1) : mapped; + return path.isAbsolute(candidate) + ? path.resolve(candidate) + : path.resolve(params.root, candidate || "."); +} + +type MemoryFlushAppendOnlyWriteOptions = { + root: string; + relativePath: string; + containerWorkdir?: string; + sandbox?: { + root: string; + bridge: SandboxFsBridge; + }; +}; + +async function readOptionalUtf8File(params: { + absolutePath: string; + relativePath: string; + sandbox?: MemoryFlushAppendOnlyWriteOptions["sandbox"]; + signal?: AbortSignal; +}): Promise { + try { + if (params.sandbox) { + const stat = await params.sandbox.bridge.stat({ + filePath: params.relativePath, + cwd: params.sandbox.root, + signal: params.signal, + }); + if (!stat) { + return ""; + } + const buffer = await params.sandbox.bridge.readFile({ + filePath: params.relativePath, + cwd: params.sandbox.root, + signal: params.signal, + }); + return buffer.toString("utf-8"); + } + return await fs.readFile(params.absolutePath, "utf-8"); + } catch (error) { + if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") { + return ""; + } + throw error; + } +} + +async function appendMemoryFlushContent(params: { + absolutePath: string; + root: string; + relativePath: string; + content: string; + sandbox?: MemoryFlushAppendOnlyWriteOptions["sandbox"]; + signal?: AbortSignal; +}) { + if (!params.sandbox) { + await appendFileWithinRoot({ + rootDir: params.root, + relativePath: params.relativePath, + data: params.content, + mkdir: true, + prependNewlineIfNeeded: true, + }); + return; + } + + const existing = await readOptionalUtf8File({ + absolutePath: params.absolutePath, + relativePath: params.relativePath, + sandbox: params.sandbox, + signal: params.signal, + }); + const separator = + existing.length > 0 && !existing.endsWith("\n") && !params.content.startsWith("\n") ? "\n" : ""; + const next = `${existing}${separator}${params.content}`; + if (params.sandbox) { + const parent = path.posix.dirname(params.relativePath); + if (parent && parent !== ".") { + await params.sandbox.bridge.mkdirp({ + filePath: parent, + cwd: params.sandbox.root, + signal: params.signal, + }); + } + await params.sandbox.bridge.writeFile({ + filePath: params.relativePath, + cwd: params.sandbox.root, + data: next, + mkdir: true, + signal: params.signal, + }); + return; + } + await fs.mkdir(path.dirname(params.absolutePath), { recursive: true }); + await fs.writeFile(params.absolutePath, next, "utf-8"); +} + +export function wrapToolMemoryFlushAppendOnlyWrite( + tool: AnyAgentTool, + options: MemoryFlushAppendOnlyWriteOptions, +): AnyAgentTool { + const allowedAbsolutePath = path.resolve(options.root, options.relativePath); + return { + ...tool, + description: `${tool.description} During memory flush, this tool may only append to ${options.relativePath}.`, + execute: async (toolCallId, args, signal, onUpdate) => { + const normalized = normalizeToolParams(args); + const record = + normalized ?? + (args && typeof args === "object" ? (args as Record) : undefined); + assertRequiredParams(record, CLAUDE_PARAM_GROUPS.write, tool.name); + const filePath = + typeof record?.path === "string" && record.path.trim() ? record.path : undefined; + const content = typeof record?.content === "string" ? record.content : undefined; + if (!filePath || content === undefined) { + return tool.execute(toolCallId, normalized ?? args, signal, onUpdate); + } + + const resolvedPath = resolveToolPathAgainstWorkspaceRoot({ + filePath, + root: options.root, + containerWorkdir: options.containerWorkdir, + }); + if (resolvedPath !== allowedAbsolutePath) { + throw new Error( + `Memory flush writes are restricted to ${options.relativePath}; use that path only.`, + ); + } + + await appendMemoryFlushContent({ + absolutePath: allowedAbsolutePath, + root: options.root, + relativePath: options.relativePath, + content, + sandbox: options.sandbox, + signal, + }); + return { + content: [{ type: "text", text: `Appended content to ${options.relativePath}.` }], + details: { + path: options.relativePath, + appendOnly: true, + }, + }; + }, + }; +} + export function wrapToolWorkspaceRootGuardWithOptions( tool: AnyAgentTool, root: string, diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 543a163ab0c..14418bbd362 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -36,6 +36,7 @@ import { createSandboxedWriteTool, normalizeToolParams, patchToolSchemaForClaudeCompatibility, + wrapToolMemoryFlushAppendOnlyWrite, wrapToolWorkspaceRootGuard, wrapToolWorkspaceRootGuardWithOptions, wrapToolParamNormalization, @@ -67,6 +68,7 @@ const TOOL_DENY_BY_MESSAGE_PROVIDER: Readonly> voice: ["tts"], }; const TOOL_DENY_FOR_XAI_PROVIDERS = new Set(["web_search"]); +const MEMORY_FLUSH_ALLOWED_TOOL_NAMES = new Set(["read", "write"]); function normalizeMessageProvider(messageProvider?: string): string | undefined { const normalized = messageProvider?.trim().toLowerCase(); @@ -207,6 +209,10 @@ export function createOpenClawCodingTools(options?: { sessionId?: string; /** Stable run identifier for this agent invocation. */ runId?: string; + /** What initiated this run (for trigger-specific tool restrictions). */ + trigger?: string; + /** Relative workspace path that memory-triggered writes may append to. */ + memoryFlushWritePath?: string; agentDir?: string; workspaceDir?: string; config?: OpenClawConfig; @@ -258,6 +264,11 @@ export function createOpenClawCodingTools(options?: { }): AnyAgentTool[] { const execToolName = "exec"; const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined; + const isMemoryFlushRun = options?.trigger === "memory"; + if (isMemoryFlushRun && !options?.memoryFlushWritePath) { + throw new Error("memoryFlushWritePath required for memory-triggered tool runs"); + } + const memoryFlushWritePath = isMemoryFlushRun ? options.memoryFlushWritePath : undefined; const { agentId, globalPolicy, @@ -322,7 +333,7 @@ export function createOpenClawCodingTools(options?: { const execConfig = resolveExecConfig({ cfg: options?.config, agentId }); const fsConfig = resolveToolFsConfig({ cfg: options?.config, agentId }); const fsPolicy = createToolFsPolicy({ - workspaceOnly: fsConfig.workspaceOnly, + workspaceOnly: isMemoryFlushRun || fsConfig.workspaceOnly, }); const sandboxRoot = sandbox?.workspaceDir; const sandboxFsBridge = sandbox?.fsBridge; @@ -515,7 +526,32 @@ export function createOpenClawCodingTools(options?: { sessionId: options?.sessionId, }), ]; - const toolsForMessageProvider = applyMessageProviderToolPolicy(tools, options?.messageProvider); + const toolsForMemoryFlush = + isMemoryFlushRun && memoryFlushWritePath + ? tools.flatMap((tool) => { + if (!MEMORY_FLUSH_ALLOWED_TOOL_NAMES.has(tool.name)) { + return []; + } + if (tool.name === "write") { + return [ + wrapToolMemoryFlushAppendOnlyWrite(tool, { + root: sandboxRoot ?? workspaceRoot, + relativePath: memoryFlushWritePath, + containerWorkdir: sandbox?.containerWorkdir, + sandbox: + sandboxRoot && sandboxFsBridge + ? { root: sandboxRoot, bridge: sandboxFsBridge } + : undefined, + }), + ]; + } + return [tool]; + }) + : tools; + const toolsForMessageProvider = applyMessageProviderToolPolicy( + toolsForMemoryFlush, + options?.messageProvider, + ); const toolsForModelProvider = applyModelProviderToolPolicy(toolsForMessageProvider, { modelProvider: options?.modelProvider, modelId: options?.modelId, diff --git a/src/agents/pi-tools.workspace-only-false.test.ts b/src/agents/pi-tools.workspace-only-false.test.ts index 713315de899..fb18260db09 100644 --- a/src/agents/pi-tools.workspace-only-false.test.ts +++ b/src/agents/pi-tools.workspace-only-false.test.ts @@ -1,7 +1,13 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@mariozechner/pi-ai/oauth", () => ({ + getOAuthApiKey: () => undefined, + getOAuthProviders: () => [], +})); + import { createOpenClawCodingTools } from "./pi-tools.js"; describe("FS tools with workspaceOnly=false", () => { @@ -181,4 +187,50 @@ describe("FS tools with workspaceOnly=false", () => { }), ).rejects.toThrow(/Path escapes (workspace|sandbox) root/); }); + + it("restricts memory-triggered writes to append-only canonical memory files", async () => { + const allowedRelativePath = "memory/2026-03-07.md"; + const allowedAbsolutePath = path.join(workspaceDir, allowedRelativePath); + await fs.mkdir(path.dirname(allowedAbsolutePath), { recursive: true }); + await fs.writeFile(allowedAbsolutePath, "seed"); + + const tools = createOpenClawCodingTools({ + workspaceDir, + trigger: "memory", + memoryFlushWritePath: allowedRelativePath, + config: { + tools: { + exec: { + applyPatch: { + enabled: true, + }, + }, + }, + }, + modelProvider: "openai", + modelId: "gpt-5", + }); + + const writeTool = tools.find((tool) => tool.name === "write"); + expect(writeTool).toBeDefined(); + expect(tools.map((tool) => tool.name).toSorted()).toEqual(["read", "write"]); + + await expect( + writeTool!.execute("test-call-memory-deny", { + path: outsideFile, + content: "should not write here", + }), + ).rejects.toThrow(/Memory flush writes are restricted to memory\/2026-03-07\.md/); + + const result = await writeTool!.execute("test-call-memory-append", { + path: allowedRelativePath, + content: "new note", + }); + expect(hasToolError(result)).toBe(false); + expect(result.content).toContainEqual({ + type: "text", + text: "Appended content to memory/2026-03-07.md.", + }); + await expect(fs.readFile(allowedAbsolutePath, "utf-8")).resolves.toBe("seed\nnew note"); + }); }); diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index 643611d35a2..623bb9c1490 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -34,6 +34,7 @@ import { import { hasAlreadyFlushedForCurrentCompaction, resolveMemoryFlushContextWindowTokens, + resolveMemoryFlushRelativePathForRun, resolveMemoryFlushPromptForRun, resolveMemoryFlushSettings, shouldRunMemoryFlush, @@ -465,6 +466,11 @@ export async function runMemoryFlushIfNeeded(params: { }); } let memoryCompactionCompleted = false; + const memoryFlushNowMs = Date.now(); + const memoryFlushWritePath = resolveMemoryFlushRelativePathForRun({ + cfg: params.cfg, + nowMs: memoryFlushNowMs, + }); const flushSystemPrompt = [ params.followupRun.run.extraSystemPrompt, memoryFlushSettings.systemPrompt, @@ -495,9 +501,11 @@ export async function runMemoryFlushIfNeeded(params: { ...senderContext, ...runBaseParams, trigger: "memory", + memoryFlushWritePath, prompt: resolveMemoryFlushPromptForRun({ prompt: memoryFlushSettings.prompt, cfg: params.cfg, + nowMs: memoryFlushNowMs, }), extraSystemPrompt: flushSystemPrompt, bootstrapPromptWarningSignaturesSeen, 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 db034ac03a6..599a8fd6a48 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts @@ -28,6 +28,7 @@ type AgentRunParams = { type EmbeddedRunParams = { prompt?: string; extraSystemPrompt?: string; + memoryFlushWritePath?: string; bootstrapPromptWarningSignaturesSeen?: string[]; bootstrapPromptWarningSignature?: string; onAgentEvent?: (evt: { stream?: string; data?: { phase?: string; willRetry?: boolean } }) => void; @@ -1611,9 +1612,14 @@ describe("runReplyAgent memory flush", () => { const flushCall = calls[0]; expect(flushCall?.prompt).toContain("Write notes."); expect(flushCall?.prompt).toContain("NO_REPLY"); + expect(flushCall?.prompt).toMatch(/memory\/\d{4}-\d{2}-\d{2}\.md/); + expect(flushCall?.prompt).toContain("MEMORY.md"); + expect(flushCall?.memoryFlushWritePath).toMatch(/^memory\/\d{4}-\d{2}-\d{2}\.md$/); expect(flushCall?.extraSystemPrompt).toContain("extra system"); expect(flushCall?.extraSystemPrompt).toContain("Flush memory now."); expect(flushCall?.extraSystemPrompt).toContain("NO_REPLY"); + expect(flushCall?.extraSystemPrompt).toContain("memory/YYYY-MM-DD.md"); + expect(flushCall?.extraSystemPrompt).toContain("MEMORY.md"); expect(calls[1]?.prompt).toBe("hello"); }); }); @@ -1701,9 +1707,17 @@ describe("runReplyAgent memory flush", () => { await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); - const calls: Array<{ prompt?: string }> = []; + const calls: Array<{ + prompt?: string; + extraSystemPrompt?: string; + memoryFlushWritePath?: string; + }> = []; state.runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => { - calls.push({ prompt: params.prompt }); + calls.push({ + prompt: params.prompt, + extraSystemPrompt: params.extraSystemPrompt, + memoryFlushWritePath: params.memoryFlushWritePath, + }); if (params.prompt?.includes("Pre-compaction memory flush.")) { return { payloads: [], meta: {} }; } @@ -1730,6 +1744,10 @@ describe("runReplyAgent memory flush", () => { expect(calls[0]?.prompt).toContain("Pre-compaction memory flush."); expect(calls[0]?.prompt).toContain("Current time:"); expect(calls[0]?.prompt).toMatch(/memory\/\d{4}-\d{2}-\d{2}\.md/); + expect(calls[0]?.prompt).toContain("MEMORY.md"); + expect(calls[0]?.memoryFlushWritePath).toMatch(/^memory\/\d{4}-\d{2}-\d{2}\.md$/); + expect(calls[0]?.extraSystemPrompt).toContain("memory/YYYY-MM-DD.md"); + expect(calls[0]?.extraSystemPrompt).toContain("MEMORY.md"); expect(calls[1]?.prompt).toBe("hello"); const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); diff --git a/src/auto-reply/reply/memory-flush.test.ts b/src/auto-reply/reply/memory-flush.test.ts index 0e04e7e0ea3..079c5578676 100644 --- a/src/auto-reply/reply/memory-flush.test.ts +++ b/src/auto-reply/reply/memory-flush.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; -import { DEFAULT_MEMORY_FLUSH_PROMPT, resolveMemoryFlushPromptForRun } from "./memory-flush.js"; +import { + DEFAULT_MEMORY_FLUSH_PROMPT, + resolveMemoryFlushPromptForRun, + resolveMemoryFlushRelativePathForRun, +} from "./memory-flush.js"; describe("resolveMemoryFlushPromptForRun", () => { const cfg = { @@ -35,6 +39,15 @@ describe("resolveMemoryFlushPromptForRun", () => { expect(prompt).toContain("Current time: already present"); expect((prompt.match(/Current time:/g) ?? []).length).toBe(1); }); + + it("resolves the canonical relative memory path using user timezone", () => { + const relativePath = resolveMemoryFlushRelativePathForRun({ + cfg, + nowMs: Date.UTC(2026, 1, 16, 15, 0, 0), + }); + + expect(relativePath).toBe("memory/2026-02-16.md"); + }); }); describe("DEFAULT_MEMORY_FLUSH_PROMPT", () => { diff --git a/src/auto-reply/reply/memory-flush.ts b/src/auto-reply/reply/memory-flush.ts index c02fad5eca0..95f6dbaa053 100644 --- a/src/auto-reply/reply/memory-flush.ts +++ b/src/auto-reply/reply/memory-flush.ts @@ -10,10 +10,23 @@ import { SILENT_REPLY_TOKEN } from "../tokens.js"; export const DEFAULT_MEMORY_FLUSH_SOFT_TOKENS = 4000; export const DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES = 2 * 1024 * 1024; +const MEMORY_FLUSH_TARGET_HINT = + "Store durable memories only in memory/YYYY-MM-DD.md (create memory/ if needed)."; +const MEMORY_FLUSH_APPEND_ONLY_HINT = + "If memory/YYYY-MM-DD.md already exists, APPEND new content only and do not overwrite existing entries."; +const MEMORY_FLUSH_READ_ONLY_HINT = + "Treat workspace bootstrap/reference files such as MEMORY.md, SOUL.md, TOOLS.md, and AGENTS.md as read-only during this flush; never overwrite, replace, or edit them."; +const MEMORY_FLUSH_REQUIRED_HINTS = [ + MEMORY_FLUSH_TARGET_HINT, + MEMORY_FLUSH_APPEND_ONLY_HINT, + MEMORY_FLUSH_READ_ONLY_HINT, +]; + export const DEFAULT_MEMORY_FLUSH_PROMPT = [ "Pre-compaction memory flush.", - "Store durable memories now (use memory/YYYY-MM-DD.md; create memory/ if needed).", - "IMPORTANT: If the file already exists, APPEND new content only — do not overwrite existing entries.", + MEMORY_FLUSH_TARGET_HINT, + MEMORY_FLUSH_READ_ONLY_HINT, + MEMORY_FLUSH_APPEND_ONLY_HINT, "Do NOT create timestamped variant files (e.g., YYYY-MM-DD-HHMM.md); always use the canonical YYYY-MM-DD.md filename.", `If nothing to store, reply with ${SILENT_REPLY_TOKEN}.`, ].join(" "); @@ -21,6 +34,9 @@ export const DEFAULT_MEMORY_FLUSH_PROMPT = [ export const DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT = [ "Pre-compaction memory flush turn.", "The session is near auto-compaction; capture durable memories to disk.", + MEMORY_FLUSH_TARGET_HINT, + MEMORY_FLUSH_READ_ONLY_HINT, + MEMORY_FLUSH_APPEND_ONLY_HINT, `You may reply, but usually ${SILENT_REPLY_TOKEN} is correct.`, ].join(" "); @@ -40,14 +56,29 @@ function formatDateStampInTimezone(nowMs: number, timezone: string): string { return new Date(nowMs).toISOString().slice(0, 10); } +export function resolveMemoryFlushRelativePathForRun(params: { + cfg?: OpenClawConfig; + nowMs?: number; +}): string { + const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now(); + const { userTimezone } = resolveCronStyleNow(params.cfg ?? {}, nowMs); + const dateStamp = formatDateStampInTimezone(nowMs, userTimezone); + return `memory/${dateStamp}.md`; +} + export function resolveMemoryFlushPromptForRun(params: { prompt: string; cfg?: OpenClawConfig; nowMs?: number; }): string { const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now(); - const { userTimezone, timeLine } = resolveCronStyleNow(params.cfg ?? {}, nowMs); - const dateStamp = formatDateStampInTimezone(nowMs, userTimezone); + const { timeLine } = resolveCronStyleNow(params.cfg ?? {}, nowMs); + const dateStamp = resolveMemoryFlushRelativePathForRun({ + cfg: params.cfg, + nowMs, + }) + .replace(/^memory\//, "") + .replace(/\.md$/, ""); const withDate = params.prompt.replaceAll("YYYY-MM-DD", dateStamp).trimEnd(); if (!withDate) { return timeLine; @@ -90,8 +121,12 @@ export function resolveMemoryFlushSettings(cfg?: OpenClawConfig): MemoryFlushSet const forceFlushTranscriptBytes = parseNonNegativeByteSize(defaults?.forceFlushTranscriptBytes) ?? DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES; - const prompt = defaults?.prompt?.trim() || DEFAULT_MEMORY_FLUSH_PROMPT; - const systemPrompt = defaults?.systemPrompt?.trim() || DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT; + const prompt = ensureMemoryFlushSafetyHints( + defaults?.prompt?.trim() || DEFAULT_MEMORY_FLUSH_PROMPT, + ); + const systemPrompt = ensureMemoryFlushSafetyHints( + defaults?.systemPrompt?.trim() || DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT, + ); const reserveTokensFloor = normalizeNonNegativeInt(cfg?.agents?.defaults?.compaction?.reserveTokensFloor) ?? DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR; @@ -113,6 +148,16 @@ function ensureNoReplyHint(text: string): string { return `${text}\n\nIf no user-visible reply is needed, start with ${SILENT_REPLY_TOKEN}.`; } +function ensureMemoryFlushSafetyHints(text: string): string { + let next = text.trim(); + for (const hint of MEMORY_FLUSH_REQUIRED_HINTS) { + if (!next.includes(hint)) { + next = next ? `${next}\n\n${hint}` : hint; + } + } + return next; +} + export function resolveMemoryFlushContextWindowTokens(params: { modelId?: string; agentCfgContextTokens?: number; diff --git a/src/auto-reply/reply/reply-state.test.ts b/src/auto-reply/reply/reply-state.test.ts index 56623fe6cfa..69dbad531e7 100644 --- a/src/auto-reply/reply/reply-state.test.ts +++ b/src/auto-reply/reply/reply-state.test.ts @@ -203,6 +203,10 @@ describe("memory flush settings", () => { expect(settings?.forceFlushTranscriptBytes).toBe(DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES); expect(settings?.prompt.length).toBeGreaterThan(0); expect(settings?.systemPrompt.length).toBeGreaterThan(0); + expect(settings?.prompt).toContain("memory/YYYY-MM-DD.md"); + expect(settings?.prompt).toContain("MEMORY.md"); + expect(settings?.systemPrompt).toContain("memory/YYYY-MM-DD.md"); + expect(settings?.systemPrompt).toContain("MEMORY.md"); }); it("respects disable flag", () => { @@ -230,6 +234,10 @@ describe("memory flush settings", () => { }); expect(settings?.prompt).toContain("NO_REPLY"); expect(settings?.systemPrompt).toContain("NO_REPLY"); + expect(settings?.prompt).toContain("memory/YYYY-MM-DD.md"); + expect(settings?.prompt).toContain("MEMORY.md"); + expect(settings?.systemPrompt).toContain("memory/YYYY-MM-DD.md"); + expect(settings?.systemPrompt).toContain("MEMORY.md"); }); it("falls back to defaults when numeric values are invalid", () => { diff --git a/src/infra/fs-safe.test.ts b/src/infra/fs-safe.test.ts index a8372a86c70..ba4c13dfc7c 100644 --- a/src/infra/fs-safe.test.ts +++ b/src/infra/fs-safe.test.ts @@ -7,6 +7,7 @@ import { } from "../test-utils/symlink-rebind-race.js"; import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js"; import { + appendFileWithinRoot, copyFileWithinRoot, createRootScopedReadFile, SafeOpenError, @@ -246,6 +247,22 @@ describe("fs-safe", () => { await expect(fs.readFile(path.join(root, "nested", "out.txt"), "utf8")).resolves.toBe("hello"); }); + it("appends to a file within root safely", async () => { + const root = await tempDirs.make("openclaw-fs-safe-root-"); + const targetPath = path.join(root, "nested", "out.txt"); + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.writeFile(targetPath, "seed"); + + await appendFileWithinRoot({ + rootDir: root, + relativePath: "nested/out.txt", + data: "next", + prependNewlineIfNeeded: true, + }); + + await expect(fs.readFile(targetPath, "utf8")).resolves.toBe("seed\nnext"); + }); + it("does not truncate existing target when atomic rename fails", async () => { const root = await tempDirs.make("openclaw-fs-safe-root-"); const targetPath = path.join(root, "nested", "out.txt"); @@ -439,6 +456,25 @@ describe("fs-safe", () => { }); }); + it.runIf(process.platform !== "win32")("rejects appending through hardlink aliases", async () => { + const root = await tempDirs.make("openclaw-fs-safe-root-"); + const hardlinkPath = path.join(root, "alias.txt"); + await withOutsideHardlinkAlias({ + aliasPath: hardlinkPath, + run: async (outsideFile) => { + await expect( + appendFileWithinRoot({ + rootDir: root, + relativePath: "alias.txt", + data: "pwned", + prependNewlineIfNeeded: true, + }), + ).rejects.toMatchObject({ code: "invalid-path" }); + await expect(fs.readFile(outsideFile, "utf8")).resolves.toBe("outside"); + }, + }); + }); + it("does not truncate out-of-root file when symlink retarget races write open", async () => { const { root, outside, slot, outsideTarget } = await setupSymlinkWriteRaceFixture({ seedInsideTarget: true, @@ -459,6 +495,27 @@ describe("fs-safe", () => { await expect(fs.readFile(outsideTarget, "utf8")).resolves.toBe("X".repeat(4096)); }); + it("does not clobber out-of-root file when symlink retarget races append open", async () => { + const { root, outside, slot, outsideTarget } = await setupSymlinkWriteRaceFixture({ + seedInsideTarget: true, + }); + + await expectSymlinkWriteRaceRejectsOutside({ + slotPath: slot, + outsideDir: outside, + runWrite: async (relativePath) => + await appendFileWithinRoot({ + rootDir: root, + relativePath, + data: "new-content", + mkdir: false, + prependNewlineIfNeeded: true, + }), + }); + + await expect(fs.readFile(outsideTarget, "utf8")).resolves.toBe("X".repeat(4096)); + }); + it("does not clobber out-of-root file when symlink retarget races write-from-path open", async () => { const { root, outside, slot, outsideTarget } = await setupSymlinkWriteRaceFixture(); const sourceDir = await tempDirs.make("openclaw-fs-safe-source-"); diff --git a/src/infra/fs-safe.ts b/src/infra/fs-safe.ts index 3a0f28ddd2c..77754437528 100644 --- a/src/infra/fs-safe.ts +++ b/src/infra/fs-safe.ts @@ -57,6 +57,14 @@ const OPEN_WRITE_CREATE_FLAGS = fsConstants.O_CREAT | fsConstants.O_EXCL | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0); +const OPEN_APPEND_EXISTING_FLAGS = + fsConstants.O_RDWR | fsConstants.O_APPEND | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0); +const OPEN_APPEND_CREATE_FLAGS = + fsConstants.O_RDWR | + fsConstants.O_APPEND | + fsConstants.O_CREAT | + fsConstants.O_EXCL | + (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0); const ensureTrailingSep = (value: string) => (value.endsWith(path.sep) ? value : value + path.sep); @@ -375,6 +383,7 @@ export async function openWritableFileWithinRoot(params: { mkdir?: boolean; mode?: number; truncateExisting?: boolean; + append?: boolean; }): Promise { const { rootReal, rootWithSep, resolved } = await resolvePathWithinRoot(params); try { @@ -410,14 +419,16 @@ export async function openWritableFileWithinRoot(params: { let handle: FileHandle; let createdForWrite = false; + const existingFlags = params.append ? OPEN_APPEND_EXISTING_FLAGS : OPEN_WRITE_EXISTING_FLAGS; + const createFlags = params.append ? OPEN_APPEND_CREATE_FLAGS : OPEN_WRITE_CREATE_FLAGS; try { try { - handle = await fs.open(ioPath, OPEN_WRITE_EXISTING_FLAGS, fileMode); + handle = await fs.open(ioPath, existingFlags, fileMode); } catch (err) { if (!isNotFoundPathError(err)) { throw err; } - handle = await fs.open(ioPath, OPEN_WRITE_CREATE_FLAGS, fileMode); + handle = await fs.open(ioPath, createFlags, fileMode); createdForWrite = true; } } catch (err) { @@ -469,7 +480,7 @@ export async function openWritableFileWithinRoot(params: { // Truncate only after boundary and identity checks complete. This avoids // irreversible side effects if a symlink target changes before validation. - if (params.truncateExisting !== false && !createdForWrite) { + if (params.append !== true && params.truncateExisting !== false && !createdForWrite) { await handle.truncate(0); } return { @@ -489,6 +500,50 @@ export async function openWritableFileWithinRoot(params: { } } +export async function appendFileWithinRoot(params: { + rootDir: string; + relativePath: string; + data: string | Buffer; + encoding?: BufferEncoding; + mkdir?: boolean; + prependNewlineIfNeeded?: boolean; +}): Promise { + const target = await openWritableFileWithinRoot({ + rootDir: params.rootDir, + relativePath: params.relativePath, + mkdir: params.mkdir, + truncateExisting: false, + append: true, + }); + try { + let prefix = ""; + if ( + params.prependNewlineIfNeeded === true && + !target.createdForWrite && + target.openedStat.size > 0 && + ((typeof params.data === "string" && !params.data.startsWith("\n")) || + (Buffer.isBuffer(params.data) && params.data.length > 0 && params.data[0] !== 0x0a)) + ) { + const lastByte = Buffer.alloc(1); + const { bytesRead } = await target.handle.read(lastByte, 0, 1, target.openedStat.size - 1); + if (bytesRead === 1 && lastByte[0] !== 0x0a) { + prefix = "\n"; + } + } + + if (typeof params.data === "string") { + await target.handle.appendFile(`${prefix}${params.data}`, params.encoding ?? "utf8"); + return; + } + + const payload = + prefix.length > 0 ? Buffer.concat([Buffer.from(prefix, "utf8"), params.data]) : params.data; + await target.handle.appendFile(payload); + } finally { + await target.handle.close().catch(() => {}); + } +} + export async function writeFileWithinRoot(params: { rootDir: string; relativePath: string; From da4fec664121b8ca443a3d72d19a6a1c9200204f Mon Sep 17 00:00:00 2001 From: Wayne <105773686+hougangdev@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:47:39 +0800 Subject: [PATCH 15/19] fix(telegram): prevent duplicate messages when preview edit times out (#41662) Merged via squash. Prepared head SHA: 2780e62d070d7b4c4d7447e966ca172e33e44ad4 Co-authored-by: hougangdev <105773686+hougangdev@users.noreply.github.com> Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com> Reviewed-by: @obviyus --- CHANGELOG.md | 1 + src/telegram/bot-message-dispatch.test.ts | 204 +++++++++++++++++++ src/telegram/bot-message-dispatch.ts | 34 +++- src/telegram/lane-delivery-text-deliverer.ts | 158 ++++++++++---- src/telegram/lane-delivery.test.ts | 147 ++++++++++++- src/telegram/lane-delivery.ts | 1 + 6 files changed, 492 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f017b834209..e80e2c34ce4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ Docs: https://docs.openclaw.ai - 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. - Control UI/Sessions: restore single-column session table collapse on narrow viewport or container widths by moving the responsive table override next to the base grid rule and enabling inline-size container queries. (#12175) Thanks @benjipeng. +- Telegram/final preview delivery: split active preview lifecycle from cleanup retention so missing archived preview edits avoid duplicate fallback sends without clearing the live preview or blocking later in-place finalization. (#41662) thanks @hougangdev. ## 2026.3.8 diff --git a/src/telegram/bot-message-dispatch.test.ts b/src/telegram/bot-message-dispatch.test.ts index 7caa7cc3af7..4f5e2484d50 100644 --- a/src/telegram/bot-message-dispatch.test.ts +++ b/src/telegram/bot-message-dispatch.test.ts @@ -906,6 +906,131 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(deliverReplies).not.toHaveBeenCalled(); }); + it("keeps the active preview when an archived final edit target is missing", async () => { + let answerMessageId: number | undefined; + let answerDraftParams: + | { + onSupersededPreview?: (preview: { messageId: number; textSnapshot: string }) => void; + } + | undefined; + const answerDraftStream = { + update: vi.fn().mockImplementation((text: string) => { + if (text.includes("Message B")) { + answerMessageId = 1002; + } + }), + flush: vi.fn().mockResolvedValue(undefined), + messageId: vi.fn().mockImplementation(() => answerMessageId), + clear: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + forceNewMessage: vi.fn().mockImplementation(() => { + answerMessageId = undefined; + }), + }; + const reasoningDraftStream = createDraftStream(); + createTelegramDraftStream + .mockImplementationOnce((params) => { + answerDraftParams = params as typeof answerDraftParams; + return answerDraftStream; + }) + .mockImplementationOnce(() => reasoningDraftStream); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation( + async ({ dispatcherOptions, replyOptions }) => { + await replyOptions?.onPartialReply?.({ text: "Message A partial" }); + await replyOptions?.onAssistantMessageStart?.(); + await replyOptions?.onPartialReply?.({ text: "Message B partial" }); + answerDraftParams?.onSupersededPreview?.({ + messageId: 1001, + textSnapshot: "Message A partial", + }); + + await dispatcherOptions.deliver({ text: "Message A final" }, { kind: "final" }); + return { queuedFinal: true }; + }, + ); + deliverReplies.mockResolvedValue({ delivered: true }); + editMessageTelegram.mockRejectedValue(new Error("400: Bad Request: message to edit not found")); + + await dispatchWithContext({ context: createContext(), streamMode: "partial" }); + + expect(editMessageTelegram).toHaveBeenCalledWith( + 123, + 1001, + "Message A final", + expect.any(Object), + ); + expect(answerDraftStream.clear).not.toHaveBeenCalled(); + expect(deliverReplies).not.toHaveBeenCalled(); + }); + + it("still finalizes the active preview after an archived final edit is retained", async () => { + let answerMessageId: number | undefined; + let answerDraftParams: + | { + onSupersededPreview?: (preview: { messageId: number; textSnapshot: string }) => void; + } + | undefined; + const answerDraftStream = { + update: vi.fn().mockImplementation((text: string) => { + if (text.includes("Message B")) { + answerMessageId = 1002; + } + }), + flush: vi.fn().mockResolvedValue(undefined), + messageId: vi.fn().mockImplementation(() => answerMessageId), + clear: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + forceNewMessage: vi.fn().mockImplementation(() => { + answerMessageId = undefined; + }), + }; + const reasoningDraftStream = createDraftStream(); + createTelegramDraftStream + .mockImplementationOnce((params) => { + answerDraftParams = params as typeof answerDraftParams; + return answerDraftStream; + }) + .mockImplementationOnce(() => reasoningDraftStream); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation( + async ({ dispatcherOptions, replyOptions }) => { + await replyOptions?.onPartialReply?.({ text: "Message A partial" }); + await replyOptions?.onAssistantMessageStart?.(); + await replyOptions?.onPartialReply?.({ text: "Message B partial" }); + answerDraftParams?.onSupersededPreview?.({ + messageId: 1001, + textSnapshot: "Message A partial", + }); + + await dispatcherOptions.deliver({ text: "Message A final" }, { kind: "final" }); + await dispatcherOptions.deliver({ text: "Message B final" }, { kind: "final" }); + return { queuedFinal: true }; + }, + ); + deliverReplies.mockResolvedValue({ delivered: true }); + editMessageTelegram + .mockRejectedValueOnce(new Error("400: Bad Request: message to edit not found")) + .mockResolvedValueOnce({ ok: true, chatId: "123", messageId: "1002" }); + + await dispatchWithContext({ context: createContext(), streamMode: "partial" }); + + expect(editMessageTelegram).toHaveBeenNthCalledWith( + 1, + 123, + 1001, + "Message A final", + expect.any(Object), + ); + expect(editMessageTelegram).toHaveBeenNthCalledWith( + 2, + 123, + 1002, + "Message B final", + expect.any(Object), + ); + expect(answerDraftStream.clear).not.toHaveBeenCalled(); + expect(deliverReplies).not.toHaveBeenCalled(); + }); + it.each(["partial", "block"] as const)( "keeps finalized text preview when the next assistant message is media-only (%s mode)", async (streamMode) => { @@ -1903,4 +2028,83 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(draftA.clear).toHaveBeenCalledTimes(1); expect(draftB.clear).toHaveBeenCalledTimes(1); }); + + it("swallows post-connect network timeout on preview edit to prevent duplicate messages", async () => { + const draftStream = createDraftStream(999); + createTelegramDraftStream.mockReturnValue(draftStream); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation( + async ({ dispatcherOptions, replyOptions }) => { + await replyOptions?.onPartialReply?.({ text: "Streaming..." }); + await dispatcherOptions.deliver({ text: "Final answer" }, { kind: "final" }); + return { queuedFinal: true }; + }, + ); + deliverReplies.mockResolvedValue({ delivered: true }); + // Simulate a post-connect timeout: editMessageTelegram throws a network + // error even though Telegram's server already processed the edit. + editMessageTelegram.mockRejectedValue(new Error("timeout: request timed out after 30000ms")); + + await dispatchWithContext({ context: createContext() }); + + expect(editMessageTelegram).toHaveBeenCalledTimes(1); + const deliverCalls = deliverReplies.mock.calls; + const finalTextSentViaDeliverReplies = deliverCalls.some((call: unknown[]) => + (call[0] as { replies?: Array<{ text?: string }> })?.replies?.some( + (r: { text?: string }) => r.text === "Final answer", + ), + ); + expect(finalTextSentViaDeliverReplies).toBe(false); + }); + + it("falls back to sendPayload on pre-connect error during final edit", async () => { + const draftStream = createDraftStream(999); + createTelegramDraftStream.mockReturnValue(draftStream); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation( + async ({ dispatcherOptions, replyOptions }) => { + await replyOptions?.onPartialReply?.({ text: "Streaming..." }); + await dispatcherOptions.deliver({ text: "Final answer" }, { kind: "final" }); + return { queuedFinal: true }; + }, + ); + deliverReplies.mockResolvedValue({ delivered: true }); + const preConnectErr = new Error("connect ECONNREFUSED 149.154.167.220:443"); + (preConnectErr as NodeJS.ErrnoException).code = "ECONNREFUSED"; + editMessageTelegram.mockRejectedValue(preConnectErr); + + await dispatchWithContext({ context: createContext() }); + + expect(editMessageTelegram).toHaveBeenCalledTimes(1); + const deliverCalls = deliverReplies.mock.calls; + const finalTextSentViaDeliverReplies = deliverCalls.some((call: unknown[]) => + (call[0] as { replies?: Array<{ text?: string }> })?.replies?.some( + (r: { text?: string }) => r.text === "Final answer", + ), + ); + expect(finalTextSentViaDeliverReplies).toBe(true); + }); + + it("falls back when Telegram reports the current final edit target missing", async () => { + const draftStream = createDraftStream(999); + createTelegramDraftStream.mockReturnValue(draftStream); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation( + async ({ dispatcherOptions, replyOptions }) => { + await replyOptions?.onPartialReply?.({ text: "Streaming..." }); + await dispatcherOptions.deliver({ text: "Final answer" }, { kind: "final" }); + return { queuedFinal: true }; + }, + ); + deliverReplies.mockResolvedValue({ delivered: true }); + editMessageTelegram.mockRejectedValue(new Error("400: Bad Request: message to edit not found")); + + await dispatchWithContext({ context: createContext() }); + + expect(editMessageTelegram).toHaveBeenCalledTimes(1); + const deliverCalls = deliverReplies.mock.calls; + const finalTextSentViaDeliverReplies = deliverCalls.some((call: unknown[]) => + (call[0] as { replies?: Array<{ text?: string }> })?.replies?.some( + (r: { text?: string }) => r.text === "Final answer", + ), + ); + expect(finalTextSentViaDeliverReplies).toBe(true); + }); }); diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index fee56211ae5..4d8d2b678e8 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -38,6 +38,7 @@ import { createLaneTextDeliverer, type DraftLaneState, type LaneName, + type LanePreviewLifecycle, } from "./lane-delivery.js"; import { createTelegramReasoningStepState, @@ -239,7 +240,14 @@ export const dispatchTelegramMessage = async ({ answer: createDraftLane("answer", canStreamAnswerDraft), reasoning: createDraftLane("reasoning", canStreamReasoningDraft), }; - const finalizedPreviewByLane: Record = { + // Active preview lifecycle answers "can this current preview still be + // finalized?" Cleanup retention is separate so archived-preview decisions do + // not poison the active lane. + const activePreviewLifecycleByLane: Record = { + answer: "transient", + reasoning: "transient", + }; + const retainPreviewOnCleanupByLane: Record = { answer: false, reasoning: false, }; @@ -288,7 +296,10 @@ export const dispatchTelegramMessage = async ({ // so it remains visible across tool boundaries. const materializedId = await answerLane.stream?.materialize?.(); const previewMessageId = materializedId ?? answerLane.stream?.messageId(); - if (typeof previewMessageId === "number" && !finalizedPreviewByLane.answer) { + if ( + typeof previewMessageId === "number" && + activePreviewLifecycleByLane.answer === "transient" + ) { archivedAnswerPreviews.push({ messageId: previewMessageId, textSnapshot: answerLane.lastPartialText, @@ -301,7 +312,8 @@ export const dispatchTelegramMessage = async ({ resetDraftLaneState(answerLane); if (didForceNewMessage) { // New assistant message boundary: this lane now tracks a fresh preview lifecycle. - finalizedPreviewByLane.answer = false; + activePreviewLifecycleByLane.answer = "transient"; + retainPreviewOnCleanupByLane.answer = false; } return didForceNewMessage; }; @@ -331,7 +343,7 @@ export const dispatchTelegramMessage = async ({ const ingestDraftLaneSegments = async (text: string | undefined) => { const split = splitTextIntoLaneSegments(text); const hasAnswerSegment = split.segments.some((segment) => segment.lane === "answer"); - if (hasAnswerSegment && finalizedPreviewByLane.answer) { + if (hasAnswerSegment && activePreviewLifecycleByLane.answer !== "transient") { // Some providers can emit the first partial of a new assistant message before // onAssistantMessageStart() arrives. Rotate preemptively so we do not edit // the previously finalized preview message with the next message's text. @@ -469,7 +481,8 @@ export const dispatchTelegramMessage = async ({ const deliverLaneText = createLaneTextDeliverer({ lanes, archivedAnswerPreviews, - finalizedPreviewByLane, + activePreviewLifecycleByLane, + retainPreviewOnCleanupByLane, draftMaxChars, applyTextToPayload, sendPayload, @@ -596,7 +609,8 @@ export const dispatchTelegramMessage = async ({ } if (info.kind === "final") { if (reasoningLane.hasStreamedMessage) { - finalizedPreviewByLane.reasoning = true; + activePreviewLifecycleByLane.reasoning = "complete"; + retainPreviewOnCleanupByLane.reasoning = true; } reasoningStepState.resetForNextStep(); } @@ -674,7 +688,8 @@ export const dispatchTelegramMessage = async ({ reasoningStepState.resetForNextStep(); if (skipNextAnswerMessageStartRotation) { skipNextAnswerMessageStartRotation = false; - finalizedPreviewByLane.answer = false; + activePreviewLifecycleByLane.answer = "transient"; + retainPreviewOnCleanupByLane.answer = false; return; } await rotateAnswerLaneForNewAssistantMessage(); @@ -682,7 +697,8 @@ export const dispatchTelegramMessage = async ({ // Even when no forceNewMessage happened (e.g. prior answer had no // streamed partials), the next partial belongs to a fresh lifecycle // and must not trigger late pre-rotation mid-message. - finalizedPreviewByLane.answer = false; + activePreviewLifecycleByLane.answer = "transient"; + retainPreviewOnCleanupByLane.answer = false; }) : undefined, onReasoningEnd: reasoningLane.stream @@ -731,7 +747,7 @@ export const dispatchTelegramMessage = async ({ (p) => p.deleteIfUnused === false && p.messageId === activePreviewMessageId, ); const shouldClear = - !finalizedPreviewByLane[laneState.laneName] && !hasBoundaryFinalizedActivePreview; + !retainPreviewOnCleanupByLane[laneState.laneName] && !hasBoundaryFinalizedActivePreview; const existing = streamCleanupStates.get(stream); if (!existing) { streamCleanupStates.set(stream, { shouldClear }); diff --git a/src/telegram/lane-delivery-text-deliverer.ts b/src/telegram/lane-delivery-text-deliverer.ts index f244d086657..c8eb10a9bb1 100644 --- a/src/telegram/lane-delivery-text-deliverer.ts +++ b/src/telegram/lane-delivery-text-deliverer.ts @@ -1,22 +1,36 @@ import type { ReplyPayload } from "../auto-reply/types.js"; import type { TelegramInlineButtons } from "./button-types.js"; import type { TelegramDraftStream } from "./draft-stream.js"; +import { isRecoverableTelegramNetworkError, isSafeToRetrySendError } from "./network-errors.js"; const MESSAGE_NOT_MODIFIED_RE = /400:\s*Bad Request:\s*message is not modified|MESSAGE_NOT_MODIFIED/i; +const MESSAGE_NOT_FOUND_RE = + /400:\s*Bad Request:\s*message to edit not found|MESSAGE_ID_INVALID|message can't be edited/i; + +function extractErrorText(err: unknown): string { + return typeof err === "string" + ? err + : err instanceof Error + ? err.message + : typeof err === "object" && err && "description" in err + ? typeof err.description === "string" + ? err.description + : "" + : ""; +} function isMessageNotModifiedError(err: unknown): boolean { - const text = - typeof err === "string" - ? err - : err instanceof Error - ? err.message - : typeof err === "object" && err && "description" in err - ? typeof err.description === "string" - ? err.description - : "" - : ""; - return MESSAGE_NOT_MODIFIED_RE.test(text); + return MESSAGE_NOT_MODIFIED_RE.test(extractErrorText(err)); +} + +/** + * Returns true when Telegram rejects an edit because the target message can no + * longer be resolved or edited. The caller still needs preview context to + * decide whether to retain a different visible preview or fall back to send. + */ +function isMissingPreviewMessageError(err: unknown): boolean { + return MESSAGE_NOT_FOUND_RE.test(extractErrorText(err)); } export type LaneName = "answer" | "reasoning"; @@ -35,12 +49,20 @@ export type ArchivedPreview = { deleteIfUnused?: boolean; }; -export type LaneDeliveryResult = "preview-finalized" | "preview-updated" | "sent" | "skipped"; +export type LanePreviewLifecycle = "transient" | "complete"; + +export type LaneDeliveryResult = + | "preview-finalized" + | "preview-retained" + | "preview-updated" + | "sent" + | "skipped"; type CreateLaneTextDelivererParams = { lanes: Record; archivedAnswerPreviews: ArchivedPreview[]; - finalizedPreviewByLane: Record; + activePreviewLifecycleByLane: Record; + retainPreviewOnCleanupByLane: Record; draftMaxChars: number; applyTextToPayload: (payload: ReplyPayload, text: string) => ReplyPayload; sendPayload: (payload: ReplyPayload) => Promise; @@ -80,6 +102,8 @@ type TryUpdatePreviewParams = { previewTextSnapshot?: string; }; +type PreviewEditResult = "edited" | "retained" | "fallback"; + type ConsumeArchivedAnswerPreviewParams = { lane: DraftLaneState; text: string; @@ -139,6 +163,10 @@ function resolvePreviewTarget(params: ResolvePreviewTargetParams): PreviewTarget export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { const getLanePreviewText = (lane: DraftLaneState) => lane.lastPartialText; + const markActivePreviewComplete = (laneName: LaneName) => { + params.activePreviewLifecycleByLane[laneName] = "complete"; + params.retainPreviewOnCleanupByLane[laneName] = true; + }; const isDraftPreviewLane = (lane: DraftLaneState) => lane.stream?.previewMode?.() === "draft"; const canMaterializeDraftFinal = ( lane: DraftLaneState, @@ -184,8 +212,9 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { previewButtons?: TelegramInlineButtons; updateLaneSnapshot: boolean; lane: DraftLaneState; - treatEditFailureAsDelivered: boolean; - }): Promise => { + finalTextAlreadyLanded: boolean; + retainAlternatePreviewOnMissingTarget: boolean; + }): Promise => { try { await params.editPreview({ laneName: args.laneName, @@ -198,26 +227,58 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { args.lane.lastPartialText = args.text; } params.markDelivered(); - return true; + return "edited"; } catch (err) { if (isMessageNotModifiedError(err)) { params.log( `telegram: ${args.laneName} preview ${args.context} edit returned "message is not modified"; treating as delivered`, ); params.markDelivered(); - return true; + return "edited"; } - if (args.treatEditFailureAsDelivered) { + if (args.context === "final") { + if (args.finalTextAlreadyLanded) { + params.log( + `telegram: ${args.laneName} preview final edit failed after stop flush; keeping existing preview (${String(err)})`, + ); + params.markDelivered(); + return "retained"; + } + if (isSafeToRetrySendError(err)) { + params.log( + `telegram: ${args.laneName} preview final edit failed before reaching Telegram; falling back to standard send (${String(err)})`, + ); + return "fallback"; + } + if (isMissingPreviewMessageError(err)) { + if (args.retainAlternatePreviewOnMissingTarget) { + params.log( + `telegram: ${args.laneName} preview final edit target missing; keeping alternate preview without fallback (${String(err)})`, + ); + params.markDelivered(); + return "retained"; + } + params.log( + `telegram: ${args.laneName} preview final edit target missing with no alternate preview; falling back to standard send (${String(err)})`, + ); + return "fallback"; + } + if (isRecoverableTelegramNetworkError(err, { allowMessageMatch: true })) { + params.log( + `telegram: ${args.laneName} preview final edit may have landed despite network error; keeping existing preview (${String(err)})`, + ); + params.markDelivered(); + return "retained"; + } params.log( - `telegram: ${args.laneName} preview ${args.context} edit failed after stop-created flush; treating as delivered (${String(err)})`, + `telegram: ${args.laneName} preview final edit rejected by Telegram; falling back to standard send (${String(err)})`, ); - params.markDelivered(); - return true; + return "fallback"; } params.log( `telegram: ${args.laneName} preview ${args.context} edit failed; falling back to standard send (${String(err)})`, ); - return false; + return "fallback"; } }; @@ -232,8 +293,12 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { context, previewMessageId: previewMessageIdOverride, previewTextSnapshot, - }: TryUpdatePreviewParams): Promise => { - const editPreview = (messageId: number, treatEditFailureAsDelivered: boolean) => + }: TryUpdatePreviewParams): Promise => { + const editPreview = ( + messageId: number, + finalTextAlreadyLanded: boolean, + retainAlternatePreviewOnMissingTarget: boolean, + ) => tryEditPreviewMessage({ laneName, messageId, @@ -242,13 +307,15 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { previewButtons, updateLaneSnapshot, lane, - treatEditFailureAsDelivered, + finalTextAlreadyLanded, + retainAlternatePreviewOnMissingTarget, }); const finalizePreview = ( previewMessageId: number, - treatEditFailureAsDelivered: boolean, + finalTextAlreadyLanded: boolean, hadPreviewMessage: boolean, - ): boolean | Promise => { + retainAlternatePreviewOnMissingTarget = false, + ): PreviewEditResult | Promise => { const currentPreviewText = previewTextSnapshot ?? getLanePreviewText(lane); const shouldSkipRegressive = shouldSkipRegressivePreviewUpdate({ currentPreviewText, @@ -258,12 +325,16 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { }); if (shouldSkipRegressive) { params.markDelivered(); - return true; + return "edited"; } - return editPreview(previewMessageId, treatEditFailureAsDelivered); + return editPreview( + previewMessageId, + finalTextAlreadyLanded, + retainAlternatePreviewOnMissingTarget, + ); }; if (!lane.stream) { - return false; + return "fallback"; } const previewTargetBeforeStop = resolvePreviewTarget({ lane, @@ -282,7 +353,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { context, }); if (typeof previewTargetAfterStop.previewMessageId !== "number") { - return false; + return "fallback"; } return finalizePreview(previewTargetAfterStop.previewMessageId, true, false); } @@ -296,12 +367,15 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { context, }); if (typeof previewTargetAfterStop.previewMessageId !== "number") { - return false; + return "fallback"; } + const activePreviewMessageId = lane.stream?.messageId(); return finalizePreview( previewTargetAfterStop.previewMessageId, false, previewTargetAfterStop.hadPreviewMessage, + typeof activePreviewMessageId === "number" && + activePreviewMessageId !== previewTargetAfterStop.previewMessageId, ); }; @@ -328,9 +402,13 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { previewMessageId: archivedPreview.messageId, previewTextSnapshot: archivedPreview.textSnapshot, }); - if (finalized) { + if (finalized === "edited") { return "preview-finalized"; } + if (finalized === "retained") { + params.retainPreviewOnCleanupByLane.answer = true; + return "preview-retained"; + } } // Send the replacement message first, then clean up the old preview. // This avoids the visual "disappear then reappear" flash. @@ -375,7 +453,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { return archivedResult; } } - if (canEditViaPreview && !params.finalizedPreviewByLane[laneName]) { + if (canEditViaPreview && params.activePreviewLifecycleByLane[laneName] === "transient") { await params.flushDraftLane(lane); if (laneName === "answer") { const archivedResultAfterFlush = await consumeArchivedAnswerPreviewForFinal({ @@ -396,7 +474,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { text, }); if (materialized) { - params.finalizedPreviewByLane[laneName] = true; + markActivePreviewComplete(laneName); return "preview-finalized"; } } @@ -409,10 +487,14 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { skipRegressive: "existingOnly", context: "final", }); - if (finalized) { - params.finalizedPreviewByLane[laneName] = true; + if (finalized === "edited") { + markActivePreviewComplete(laneName); return "preview-finalized"; } + if (finalized === "retained") { + markActivePreviewComplete(laneName); + return "preview-retained"; + } } else if (!hasMedia && !payload.isError && text.length > params.draftMaxChars) { params.log( `telegram: preview final too long for edit (${text.length} > ${params.draftMaxChars}); falling back to standard send`, @@ -452,7 +534,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { skipRegressive: "always", context: "update", }); - if (updated) { + if (updated === "edited") { return "preview-updated"; } } diff --git a/src/telegram/lane-delivery.test.ts b/src/telegram/lane-delivery.test.ts index 1cd1d36cf4c..a2dae1f05b9 100644 --- a/src/telegram/lane-delivery.test.ts +++ b/src/telegram/lane-delivery.test.ts @@ -42,7 +42,8 @@ function createHarness(params?: { const deletePreviewMessage = vi.fn().mockResolvedValue(undefined); const log = vi.fn(); const markDelivered = vi.fn(); - const finalizedPreviewByLane: Record = { answer: false, reasoning: false }; + const activePreviewLifecycleByLane = { answer: "transient", reasoning: "transient" } as const; + const retainPreviewOnCleanupByLane = { answer: false, reasoning: false } as const; const archivedAnswerPreviews: Array<{ messageId: number; textSnapshot: string; @@ -52,7 +53,8 @@ function createHarness(params?: { const deliverLaneText = createLaneTextDeliverer({ lanes, archivedAnswerPreviews, - finalizedPreviewByLane, + activePreviewLifecycleByLane: { ...activePreviewLifecycleByLane }, + retainPreviewOnCleanupByLane: { ...retainPreviewOnCleanupByLane }, draftMaxChars: params?.draftMaxChars ?? 4_096, applyTextToPayload: (payload: ReplyPayload, text: string) => ({ ...payload, text }), sendPayload, @@ -129,7 +131,7 @@ describe("createLaneTextDeliverer", () => { expect(harness.sendPayload).not.toHaveBeenCalled(); }); - it("treats stop-created preview edit failures as delivered", async () => { + it("keeps stop-created preview when follow-up final edit fails", async () => { const harness = createHarness({ answerMessageIdAfterStop: 777 }); harness.editPreview.mockRejectedValue(new Error("500: edit failed after stop flush")); @@ -140,10 +142,12 @@ describe("createLaneTextDeliverer", () => { infoKind: "final", }); - expect(result).toBe("preview-finalized"); + expect(result).toBe("preview-retained"); expect(harness.editPreview).toHaveBeenCalledTimes(1); expect(harness.sendPayload).not.toHaveBeenCalled(); - expect(harness.log).toHaveBeenCalledWith(expect.stringContaining("treating as delivered")); + expect(harness.log).toHaveBeenCalledWith( + expect.stringContaining("failed after stop flush; keeping existing preview"), + ); }); it("treats 'message is not modified' preview edit errors as delivered", async () => { @@ -170,7 +174,7 @@ describe("createLaneTextDeliverer", () => { ); }); - it("falls back to normal delivery when editing an existing preview fails", async () => { + it("falls back to sendPayload when an existing preview final edit is rejected", async () => { const harness = createHarness({ answerMessageId: 999 }); harness.editPreview.mockRejectedValue(new Error("500: preview edit failed")); @@ -186,6 +190,69 @@ describe("createLaneTextDeliverer", () => { expect(harness.sendPayload).toHaveBeenCalledWith( expect.objectContaining({ text: "Hello final" }), ); + expect(harness.log).toHaveBeenCalledWith( + expect.stringContaining("edit rejected by Telegram; falling back"), + ); + }); + + it("falls back when Telegram reports the current final edit target missing", async () => { + const harness = createHarness({ answerMessageId: 999 }); + harness.editPreview.mockRejectedValue(new Error("400: Bad Request: message to edit not found")); + + const result = await harness.deliverLaneText({ + laneName: "answer", + text: "Hello final", + payload: { text: "Hello final" }, + infoKind: "final", + }); + + expect(result).toBe("sent"); + expect(harness.editPreview).toHaveBeenCalledTimes(1); + expect(harness.sendPayload).toHaveBeenCalledWith( + expect.objectContaining({ text: "Hello final" }), + ); + expect(harness.log).toHaveBeenCalledWith( + expect.stringContaining("edit target missing with no alternate preview; falling back"), + ); + }); + + it("falls back to sendPayload when the final edit fails before reaching Telegram", async () => { + const harness = createHarness({ answerMessageId: 999 }); + const err = Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" }); + harness.editPreview.mockRejectedValue(err); + + const result = await harness.deliverLaneText({ + laneName: "answer", + text: "Hello final", + payload: { text: "Hello final" }, + infoKind: "final", + }); + + expect(result).toBe("sent"); + expect(harness.sendPayload).toHaveBeenCalledWith( + expect.objectContaining({ text: "Hello final" }), + ); + expect(harness.log).toHaveBeenCalledWith( + expect.stringContaining("failed before reaching Telegram; falling back"), + ); + }); + + it("keeps preview when the final edit times out after the request may have landed", async () => { + const harness = createHarness({ answerMessageId: 999 }); + harness.editPreview.mockRejectedValue(new Error("timeout: request timed out after 30000ms")); + + const result = await harness.deliverLaneText({ + laneName: "answer", + text: "Hello final", + payload: { text: "Hello final" }, + infoKind: "final", + }); + + expect(result).toBe("preview-retained"); + expect(harness.sendPayload).not.toHaveBeenCalled(); + expect(harness.log).toHaveBeenCalledWith( + expect.stringContaining("may have landed despite network error; keeping existing preview"), + ); }); it("falls back to normal delivery when stop-created preview has no message id", async () => { @@ -362,6 +429,74 @@ describe("createLaneTextDeliverer", () => { expect(harness.markDelivered).not.toHaveBeenCalled(); }); + // ── Duplicate message regression tests ────────────────────────────────── + // During final delivery, only ambiguous post-connect failures keep the + // preview. Definite non-delivery falls back to a real send. + + it("falls back on API error during final", async () => { + const harness = createHarness({ answerMessageId: 999 }); + harness.editPreview.mockRejectedValue(new Error("500: Internal Server Error")); + + const result = await harness.deliverLaneText({ + laneName: "answer", + text: "Hello final", + payload: { text: "Hello final" }, + infoKind: "final", + }); + + expect(result).toBe("sent"); + expect(harness.editPreview).toHaveBeenCalledTimes(1); + expect(harness.sendPayload).toHaveBeenCalledTimes(1); + }); + + it("falls back when an archived preview edit target is missing and no alternate preview exists", async () => { + const harness = createHarness(); + harness.archivedAnswerPreviews.push({ + messageId: 5555, + textSnapshot: "Partial streaming...", + deleteIfUnused: true, + }); + harness.editPreview.mockRejectedValue(new Error("400: Bad Request: message to edit not found")); + + const result = await harness.deliverLaneText({ + laneName: "answer", + text: "Complete final answer", + payload: { text: "Complete final answer" }, + infoKind: "final", + }); + + expect(harness.editPreview).toHaveBeenCalledTimes(1); + expect(harness.sendPayload).toHaveBeenCalledWith( + expect.objectContaining({ text: "Complete final answer" }), + ); + expect(result).toBe("sent"); + expect(harness.deletePreviewMessage).toHaveBeenCalledWith(5555); + }); + + it("keeps the active preview when an archived final edit target is missing", async () => { + const harness = createHarness({ answerMessageId: 999 }); + harness.archivedAnswerPreviews.push({ + messageId: 5555, + textSnapshot: "Partial streaming...", + deleteIfUnused: true, + }); + harness.editPreview.mockRejectedValue(new Error("400: Bad Request: message to edit not found")); + + const result = await harness.deliverLaneText({ + laneName: "answer", + text: "Complete final answer", + payload: { text: "Complete final answer" }, + infoKind: "final", + }); + + expect(harness.editPreview).toHaveBeenCalledTimes(1); + expect(harness.sendPayload).not.toHaveBeenCalled(); + expect(result).toBe("preview-retained"); + expect(harness.log).toHaveBeenCalledWith( + expect.stringContaining("edit target missing; keeping alternate preview without fallback"), + ); + }); + it("deletes consumed boundary previews after fallback final send", async () => { const harness = createHarness(); harness.archivedAnswerPreviews.push({ diff --git a/src/telegram/lane-delivery.ts b/src/telegram/lane-delivery.ts index 213b05e1158..a9114b281ff 100644 --- a/src/telegram/lane-delivery.ts +++ b/src/telegram/lane-delivery.ts @@ -4,6 +4,7 @@ export { type DraftLaneState, type LaneDeliveryResult, type LaneName, + type LanePreviewLifecycle, } from "./lane-delivery-text-deliverer.js"; export { createLaneDeliveryStateTracker, From 382287026b55e787d28f19d762380344c9f4408d Mon Sep 17 00:00:00 2001 From: futuremind2026 Date: Tue, 10 Mar 2026 13:01:45 +0800 Subject: [PATCH 16/19] cron: record lastErrorReason in job state (#14382) Merged via squash. Prepared head SHA: baa6b5d566a41950dea0a214881eef48697326d8 Co-authored-by: futuremind2026 <258860756+futuremind2026@users.noreply.github.com> Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com> Reviewed-by: @BunsDev --- CHANGELOG.md | 1 + src/cron/cron-protocol-conformance.test.ts | 27 +++++++++++++++++++++- src/cron/service/timer.ts | 6 ++++- src/cron/types.ts | 4 +++- src/gateway/protocol/schema/cron.ts | 10 ++++++++ 5 files changed, 45 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e80e2c34ce4..6bc7bf6f07f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai - 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. - Control UI/Sessions: restore single-column session table collapse on narrow viewport or container widths by moving the responsive table override next to the base grid rule and enabling inline-size container queries. (#12175) Thanks @benjipeng. - Telegram/final preview delivery: split active preview lifecycle from cleanup retention so missing archived preview edits avoid duplicate fallback sends without clearing the live preview or blocking later in-place finalization. (#41662) thanks @hougangdev. +- Cron/state errors: record `lastErrorReason` in cron job state and keep the gateway schema aligned with the full failover-reason set, including regression coverage for protocol conformance. (#14382) thanks @futuremind2026. ## 2026.3.8 diff --git a/src/cron/cron-protocol-conformance.test.ts b/src/cron/cron-protocol-conformance.test.ts index 51fe8f4767c..698f5e0038d 100644 --- a/src/cron/cron-protocol-conformance.test.ts +++ b/src/cron/cron-protocol-conformance.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { MACOS_APP_SOURCES_DIR } from "../compat/legacy-names.js"; -import { CronDeliverySchema } from "../gateway/protocol/schema.js"; +import { CronDeliverySchema, CronJobStateSchema } from "../gateway/protocol/schema.js"; type SchemaLike = { anyOf?: Array; @@ -29,6 +29,16 @@ function extractDeliveryModes(schema: SchemaLike): string[] { return Array.from(new Set(unionModes)); } +function extractConstUnionValues(schema: SchemaLike): string[] { + return Array.from( + new Set( + (schema.anyOf ?? []) + .map((entry) => entry?.const) + .filter((value): value is string => typeof value === "string"), + ), + ); +} + const UI_FILES = ["ui/src/ui/types.ts", "ui/src/ui/ui-types.ts", "ui/src/ui/views/cron.ts"]; const SWIFT_MODEL_CANDIDATES = [`${MACOS_APP_SOURCES_DIR}/CronModels.swift`]; @@ -88,4 +98,19 @@ describe("cron protocol conformance", () => { expect(swift.includes("struct CronSchedulerStatus")).toBe(true); expect(swift.includes("let jobs:")).toBe(true); }); + + it("cron job state schema keeps the full failover reason set", () => { + const properties = (CronJobStateSchema as SchemaLike).properties ?? {}; + const lastErrorReason = properties.lastErrorReason as SchemaLike | undefined; + expect(lastErrorReason).toBeDefined(); + expect(extractConstUnionValues(lastErrorReason ?? {})).toEqual([ + "auth", + "format", + "rate_limit", + "billing", + "timeout", + "model_not_found", + "unknown", + ]); + }); }); diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts index 5320ffdf526..e12c4ae38e7 100644 --- a/src/cron/service/timer.ts +++ b/src/cron/service/timer.ts @@ -1,3 +1,4 @@ +import { resolveFailoverReasonFromError } from "../../agents/failover-error.js"; import type { CronConfig, CronRetryOn } from "../../config/types.cron.js"; import type { HeartbeatRunResult } from "../../infra/heartbeat-wake.js"; import { DEFAULT_AGENT_ID } from "../../routing/session-key.js"; @@ -322,6 +323,10 @@ export function applyJobResult( job.state.lastStatus = result.status; job.state.lastDurationMs = Math.max(0, result.endedAt - result.startedAt); job.state.lastError = result.error; + job.state.lastErrorReason = + result.status === "error" && typeof result.error === "string" + ? (resolveFailoverReasonFromError(result.error) ?? undefined) + : undefined; job.state.lastDelivered = result.delivered; const deliveryStatus = resolveDeliveryStatus({ job, delivered: result.delivered }); job.state.lastDeliveryStatus = deliveryStatus; @@ -670,7 +675,6 @@ export async function onTimer(state: CronServiceState) { if (completedResults.length > 0) { await locked(state, async () => { await ensureLoaded(state, { forceReload: true, skipRecompute: true }); - for (const result of completedResults) { applyOutcomeToStoredJob(state, result); } diff --git a/src/cron/types.ts b/src/cron/types.ts index ef5de924b02..2a93bc30311 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -1,3 +1,4 @@ +import type { FailoverReason } from "../agents/pi-embedded-helpers.js"; import type { ChannelId } from "../channels/plugins/types.js"; import type { CronJobBase } from "./types-shared.js"; @@ -105,7 +106,6 @@ type CronAgentTurnPayload = { type CronAgentTurnPayloadPatch = { kind: "agentTurn"; } & Partial; - export type CronJobState = { nextRunAtMs?: number; runningAtMs?: number; @@ -115,6 +115,8 @@ export type CronJobState = { /** Back-compat alias for lastRunStatus. */ lastStatus?: "ok" | "error" | "skipped"; lastError?: string; + /** Classified reason for the last error (when available). */ + lastErrorReason?: FailoverReason; lastDurationMs?: number; /** Number of consecutive execution errors (reset on success). Used for backoff. */ consecutiveErrors?: number; diff --git a/src/gateway/protocol/schema/cron.ts b/src/gateway/protocol/schema/cron.ts index 41e7467bece..3cba5a65781 100644 --- a/src/gateway/protocol/schema/cron.ts +++ b/src/gateway/protocol/schema/cron.ts @@ -56,6 +56,15 @@ const CronDeliveryStatusSchema = Type.Union([ Type.Literal("unknown"), Type.Literal("not-requested"), ]); +const CronFailoverReasonSchema = Type.Union([ + Type.Literal("auth"), + Type.Literal("format"), + Type.Literal("rate_limit"), + Type.Literal("billing"), + Type.Literal("timeout"), + Type.Literal("model_not_found"), + Type.Literal("unknown"), +]); const CronCommonOptionalFields = { agentId: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), sessionKey: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), @@ -219,6 +228,7 @@ export const CronJobStateSchema = Type.Object( lastRunStatus: Type.Optional(CronRunStatusSchema), lastStatus: Type.Optional(CronRunStatusSchema), lastError: Type.Optional(Type.String()), + lastErrorReason: Type.Optional(CronFailoverReasonSchema), lastDurationMs: Type.Optional(Type.Integer({ minimum: 0 })), consecutiveErrors: Type.Optional(Type.Integer({ minimum: 0 })), lastDelivered: Type.Optional(Type.Boolean()), From cf9db91b611c79e71281f226a401e51931d6643b Mon Sep 17 00:00:00 2001 From: Laurie Luo Date: Tue, 10 Mar 2026 13:07:44 +0800 Subject: [PATCH 17/19] fix(web-search): recover OpenRouter Perplexity citations from message annotations (#40881) Merged via squash. Prepared head SHA: 66c8bb2c6a4bbc95a5d23661c185f1e551c2929e Co-authored-by: laurieluo <89195476+laurieluo@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.ts | 45 ++++++++++++++++- .../tools/web-tools.enabled-defaults.test.ts | 48 +++++++++++++++++-- 3 files changed, 88 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bc7bf6f07f..1ba832e4692 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai - Control UI/Sessions: restore single-column session table collapse on narrow viewport or container widths by moving the responsive table override next to the base grid rule and enabling inline-size container queries. (#12175) Thanks @benjipeng. - Telegram/final preview delivery: split active preview lifecycle from cleanup retention so missing archived preview edits avoid duplicate fallback sends without clearing the live preview or blocking later in-place finalization. (#41662) thanks @hougangdev. - Cron/state errors: record `lastErrorReason` in cron job state and keep the gateway schema aligned with the full failover-reason set, including regression coverage for protocol conformance. (#14382) thanks @futuremind2026. +- Tools/web search: recover OpenRouter Perplexity citation extraction from `message.annotations` when chat-completions responses omit top-level citations. (#40881) Thanks @laurieluo. ## 2026.3.8 diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 4fbbfa95e43..6e9518f1ede 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -396,6 +396,16 @@ type PerplexitySearchResponse = { choices?: Array<{ message?: { content?: string; + annotations?: Array<{ + type?: string; + url?: string; + url_citation?: { + url?: string; + title?: string; + start_index?: number; + end_index?: number; + }; + }>; }; }>; citations?: string[]; @@ -414,6 +424,38 @@ type PerplexitySearchApiResponse = { id?: string; }; +function extractPerplexityCitations(data: PerplexitySearchResponse): string[] { + const normalizeUrl = (value: unknown): string | undefined => { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; + }; + + const topLevel = (data.citations ?? []) + .map(normalizeUrl) + .filter((url): url is string => Boolean(url)); + if (topLevel.length > 0) { + return [...new Set(topLevel)]; + } + + const citations: string[] = []; + for (const choice of data.choices ?? []) { + for (const annotation of choice.message?.annotations ?? []) { + if (annotation.type !== "url_citation") { + continue; + } + const url = normalizeUrl(annotation.url_citation?.url ?? annotation.url); + if (url) { + citations.push(url); + } + } + } + + return [...new Set(citations)]; +} + function extractGrokContent(data: GrokSearchResponse): { text: string | undefined; annotationCitations: string[]; @@ -1252,7 +1294,8 @@ async function runPerplexitySearch(params: { const data = (await res.json()) as PerplexitySearchResponse; const content = data.choices?.[0]?.message?.content ?? "No response"; - const citations = data.citations ?? []; + // Prefer top-level citations; fall back to OpenRouter-style message annotations. + const citations = extractPerplexityCitations(data); return { content, citations }; }, diff --git a/src/agents/tools/web-tools.enabled-defaults.test.ts b/src/agents/tools/web-tools.enabled-defaults.test.ts index 4951f1c6b5a..ad3345a3e06 100644 --- a/src/agents/tools/web-tools.enabled-defaults.test.ts +++ b/src/agents/tools/web-tools.enabled-defaults.test.ts @@ -113,11 +113,13 @@ function installPerplexitySearchApiFetch(results?: Array }); } -function installPerplexityChatFetch() { - return installMockFetch({ - choices: [{ message: { content: "ok" } }], - citations: ["https://example.com"], - }); +function installPerplexityChatFetch(payload?: Record) { + return installMockFetch( + payload ?? { + choices: [{ message: { content: "ok" } }], + citations: ["https://example.com"], + }, + ); } function createProviderSuccessPayload( @@ -509,6 +511,42 @@ describe("web_search perplexity OpenRouter compatibility", () => { expect(body.search_recency_filter).toBe("week"); }); + it("falls back to message annotations when top-level citations are missing", async () => { + vi.stubEnv("OPENROUTER_API_KEY", "sk-or-v1-test"); // pragma: allowlist secret + const mockFetch = installPerplexityChatFetch({ + choices: [ + { + message: { + content: "ok", + annotations: [ + { + type: "url_citation", + url_citation: { url: "https://example.com/a" }, + }, + { + type: "url_citation", + url_citation: { url: "https://example.com/b" }, + }, + { + type: "url_citation", + url_citation: { url: "https://example.com/a" }, + }, + ], + }, + }, + ], + }); + const tool = createPerplexitySearchTool(); + const result = await tool?.execute?.("call-1", { query: "test" }); + + expect(mockFetch).toHaveBeenCalled(); + expect(result?.details).toMatchObject({ + provider: "perplexity", + citations: ["https://example.com/a", "https://example.com/b"], + content: expect.stringContaining("ok"), + }); + }); + it("fails loud for Search API-only filters on the compatibility path", async () => { vi.stubEnv("OPENROUTER_API_KEY", "sk-or-v1-test"); // pragma: allowlist secret const mockFetch = installPerplexityChatFetch(); From d1a59557b517a93ac40b1892e541d383a604ab83 Mon Sep 17 00:00:00 2001 From: Urian Paul Danut Date: Tue, 10 Mar 2026 05:54:23 +0000 Subject: [PATCH 18/19] fix(security): harden replaceMarkers() to catch space/underscore boundary marker variants (#35983) Merged via squash. Prepared head SHA: ff07dc45a9c9665c0a88c9898684a5c97f76473b Co-authored-by: urianpaul94 <33277984+urianpaul94@users.noreply.github.com> Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com> Reviewed-by: @frankekn --- CHANGELOG.md | 1 + src/security/external-content.test.ts | 16 ++++++++++++++++ src/security/external-content.ts | 12 ++++++++---- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ba832e4692..2db4805cee0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ Docs: https://docs.openclaw.ai - Telegram/final preview delivery: split active preview lifecycle from cleanup retention so missing archived preview edits avoid duplicate fallback sends without clearing the live preview or blocking later in-place finalization. (#41662) thanks @hougangdev. - Cron/state errors: record `lastErrorReason` in cron job state and keep the gateway schema aligned with the full failover-reason set, including regression coverage for protocol conformance. (#14382) thanks @futuremind2026. - Tools/web search: recover OpenRouter Perplexity citation extraction from `message.annotations` when chat-completions responses omit top-level citations. (#40881) Thanks @laurieluo. +- Security/external content: treat whitespace-delimited `EXTERNAL UNTRUSTED CONTENT` boundary markers like underscore-delimited variants so prompt wrappers cannot bypass marker sanitization. (#35983) Thanks @urianpaul94. ## 2026.3.8 diff --git a/src/security/external-content.test.ts b/src/security/external-content.test.ts index 17076b642b1..b943bdacf72 100644 --- a/src/security/external-content.test.ts +++ b/src/security/external-content.test.ts @@ -138,6 +138,21 @@ describe("external-content security", () => { content: "Before <<>> middle <<>> after", }, + { + name: "sanitizes space-separated boundary markers", + content: + "Before <<>> middle <<>> after", + }, + { + name: "sanitizes mixed space/underscore boundary markers", + content: + "Before <<>> middle <<>> after", + }, + { + name: "sanitizes tab-delimited boundary markers", + content: + "Before <<>> middle <<>> after", + }, ])("$name", ({ content }) => { const result = wrapExternalContent(content, { source: "email" }); expectSanitizedBoundaryMarkers(result); @@ -204,6 +219,7 @@ describe("external-content security", () => { ["\u27EE", "\u27EF"], // flattened parentheses ["\u276C", "\u276D"], // medium angle bracket ornaments ["\u276E", "\u276F"], // heavy angle quotation ornaments + ["\u02C2", "\u02C3"], // modifier letter left/right arrowhead ]; for (const [left, right] of bracketPairs) { diff --git a/src/security/external-content.ts b/src/security/external-content.ts index 60f92584108..ff571871b5e 100644 --- a/src/security/external-content.ts +++ b/src/security/external-content.ts @@ -132,6 +132,8 @@ const ANGLE_BRACKET_MAP: Record = { 0x276d: ">", // medium right-pointing angle bracket ornament 0x276e: "<", // heavy left-pointing angle quotation mark ornament 0x276f: ">", // heavy right-pointing angle quotation mark ornament + 0x02c2: "<", // modifier letter left arrowhead + 0x02c3: ">", // modifier letter right arrowhead }; function foldMarkerChar(char: string): string { @@ -151,25 +153,27 @@ function foldMarkerChar(char: string): string { function foldMarkerText(input: string): string { return input.replace( - /[\uFF21-\uFF3A\uFF41-\uFF5A\uFF1C\uFF1E\u2329\u232A\u3008\u3009\u2039\u203A\u27E8\u27E9\uFE64\uFE65\u00AB\u00BB\u300A\u300B\u27EA\u27EB\u27EC\u27ED\u27EE\u27EF\u276C\u276D\u276E\u276F]/g, + /[\uFF21-\uFF3A\uFF41-\uFF5A\uFF1C\uFF1E\u2329\u232A\u3008\u3009\u2039\u203A\u27E8\u27E9\uFE64\uFE65\u00AB\u00BB\u300A\u300B\u27EA\u27EB\u27EC\u27ED\u27EE\u27EF\u276C\u276D\u276E\u276F\u02C2\u02C3]/g, (char) => foldMarkerChar(char), ); } function replaceMarkers(content: string): string { const folded = foldMarkerText(content); - if (!/external_untrusted_content/i.test(folded)) { + // Intentionally catch whitespace-delimited spoof variants (space, tab, newline) in addition + // to the legacy underscore form because LLMs may still parse them as trusted boundary markers. + if (!/external[\s_]+untrusted[\s_]+content/i.test(folded)) { return content; } const replacements: Array<{ start: number; end: number; value: string }> = []; // Match markers with or without id attribute (handles both legacy and spoofed markers) const patterns: Array<{ regex: RegExp; value: string }> = [ { - regex: /<<>>/gi, + regex: /<<<\s*EXTERNAL[\s_]+UNTRUSTED[\s_]+CONTENT(?:\s+id="[^"]{1,128}")?\s*>>>/gi, value: "[[MARKER_SANITIZED]]", }, { - regex: /<<>>/gi, + regex: /<<<\s*END[\s_]+EXTERNAL[\s_]+UNTRUSTED[\s_]+CONTENT(?:\s+id="[^"]{1,128}")?\s*>>>/gi, value: "[[END_MARKER_SANITIZED]]", }, ]; From 45b74fb56c45dfe40586d6763adf03a021eb09d2 Mon Sep 17 00:00:00 2001 From: Eugene Date: Tue, 10 Mar 2026 15:58:51 +1000 Subject: [PATCH 19/19] fix(telegram): move network fallback to resolver-scoped dispatchers (#40740) Merged via squash. Prepared head SHA: a4456d48b42d6c588b2858831a2391d015260a9b Co-authored-by: sircrumpet <4436535+sircrumpet@users.noreply.github.com> Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com> Reviewed-by: @obviyus --- CHANGELOG.md | 1 + extensions/telegram/src/channel.test.ts | 101 ++- extensions/telegram/src/channel.ts | 21 +- src/infra/net/proxy-fetch.test.ts | 1 + src/infra/net/proxy-fetch.ts | 35 +- src/telegram/audit-membership-runtime.ts | 4 +- src/telegram/audit.test.ts | 24 +- src/telegram/audit.ts | 2 + src/telegram/bot-handlers.ts | 7 +- src/telegram/bot-native-commands.ts | 1 + src/telegram/bot.media.e2e-harness.ts | 11 + src/telegram/bot.ts | 1 + .../bot/delivery.resolve-media-retry.test.ts | 56 ++ src/telegram/bot/delivery.resolve-media.ts | 32 +- src/telegram/fetch.env-proxy-runtime.test.ts | 58 ++ src/telegram/fetch.test.ts | 844 +++++++++++++----- src/telegram/fetch.ts | 415 +++++++-- src/telegram/probe.test.ts | 162 +++- src/telegram/probe.ts | 131 ++- src/telegram/proxy.test.ts | 9 +- src/telegram/proxy.ts | 2 +- src/telegram/send.proxy.test.ts | 36 +- src/telegram/send.ts | 77 +- 23 files changed, 1641 insertions(+), 390 deletions(-) create mode 100644 src/telegram/fetch.env-proxy-runtime.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2db4805cee0..2e2e65653c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai - Cron/state errors: record `lastErrorReason` in cron job state and keep the gateway schema aligned with the full failover-reason set, including regression coverage for protocol conformance. (#14382) thanks @futuremind2026. - Tools/web search: recover OpenRouter Perplexity citation extraction from `message.annotations` when chat-completions responses omit top-level citations. (#40881) Thanks @laurieluo. - Security/external content: treat whitespace-delimited `EXTERNAL UNTRUSTED CONTENT` boundary markers like underscore-delimited variants so prompt wrappers cannot bypass marker sanitization. (#35983) Thanks @urianpaul94. +- Telegram/network env-proxy: apply configured transport policy to proxied HTTPS dispatchers as well as direct `NO_PROXY` bypasses, so resolver-scoped IPv4 fallback and network settings work consistently for env-proxied Telegram traffic. (#40740) Thanks @sircrumpet. ## 2026.3.8 diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts index c1912db56f0..2bf1b681497 100644 --- a/extensions/telegram/src/channel.test.ts +++ b/extensions/telegram/src/channel.test.ts @@ -57,18 +57,38 @@ function installGatewayRuntime(params?: { probeOk?: boolean; botUsername?: strin const probeTelegram = vi.fn(async () => params?.probeOk ? { ok: true, bot: { username: params.botUsername ?? "bot" } } : { ok: false }, ); + const collectUnmentionedGroupIds = vi.fn(() => ({ + groupIds: [] as string[], + unresolvedGroups: 0, + hasWildcardUnmentionedGroups: false, + })); + const auditGroupMembership = vi.fn(async () => ({ + ok: true, + checkedGroups: 0, + unresolvedGroups: 0, + hasWildcardUnmentionedGroups: false, + groups: [], + elapsedMs: 0, + })); setTelegramRuntime({ channel: { telegram: { monitorTelegramProvider, probeTelegram, + collectUnmentionedGroupIds, + auditGroupMembership, }, }, logging: { shouldLogVerbose: () => false, }, } as unknown as PluginRuntime); - return { monitorTelegramProvider, probeTelegram }; + return { + monitorTelegramProvider, + probeTelegram, + collectUnmentionedGroupIds, + auditGroupMembership, + }; } describe("telegramPlugin duplicate token guard", () => { @@ -149,6 +169,85 @@ describe("telegramPlugin duplicate token guard", () => { ); }); + it("passes account proxy and network settings into Telegram probes", async () => { + const { probeTelegram } = installGatewayRuntime({ + probeOk: true, + botUsername: "opsbot", + }); + + const cfg = createCfg(); + cfg.channels!.telegram!.accounts!.ops = { + ...cfg.channels!.telegram!.accounts!.ops, + proxy: "http://127.0.0.1:8888", + network: { + autoSelectFamily: false, + dnsResultOrder: "ipv4first", + }, + }; + const account = telegramPlugin.config.resolveAccount(cfg, "ops"); + + await telegramPlugin.status!.probeAccount!({ + account, + timeoutMs: 5000, + cfg, + }); + + expect(probeTelegram).toHaveBeenCalledWith("token-ops", 5000, { + accountId: "ops", + proxyUrl: "http://127.0.0.1:8888", + network: { + autoSelectFamily: false, + dnsResultOrder: "ipv4first", + }, + }); + }); + + it("passes account proxy and network settings into Telegram membership audits", async () => { + const { collectUnmentionedGroupIds, auditGroupMembership } = installGatewayRuntime({ + probeOk: true, + botUsername: "opsbot", + }); + + collectUnmentionedGroupIds.mockReturnValue({ + groupIds: ["-100123"], + unresolvedGroups: 0, + hasWildcardUnmentionedGroups: false, + }); + + const cfg = createCfg(); + cfg.channels!.telegram!.accounts!.ops = { + ...cfg.channels!.telegram!.accounts!.ops, + proxy: "http://127.0.0.1:8888", + network: { + autoSelectFamily: false, + dnsResultOrder: "ipv4first", + }, + groups: { + "-100123": { requireMention: false }, + }, + }; + const account = telegramPlugin.config.resolveAccount(cfg, "ops"); + + await telegramPlugin.status!.auditAccount!({ + account, + timeoutMs: 5000, + probe: { ok: true, bot: { id: 123 }, elapsedMs: 1 }, + cfg, + }); + + expect(auditGroupMembership).toHaveBeenCalledWith({ + token: "token-ops", + botId: 123, + groupIds: ["-100123"], + proxyUrl: "http://127.0.0.1:8888", + network: { + autoSelectFamily: false, + dnsResultOrder: "ipv4first", + }, + timeoutMs: 5000, + }); + }); + it("forwards mediaLocalRoots to sendMessageTelegram for outbound media sends", async () => { const sendMessageTelegram = vi.fn(async () => ({ messageId: "tg-1" })); setTelegramRuntime({ diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 7ea0a7a6525..5893f4e0a2e 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -438,11 +438,11 @@ export const telegramPlugin: ChannelPlugin buildTokenChannelStatusSummary(snapshot), probeAccount: async ({ account, timeoutMs }) => - getTelegramRuntime().channel.telegram.probeTelegram( - account.token, - timeoutMs, - account.config.proxy, - ), + getTelegramRuntime().channel.telegram.probeTelegram(account.token, timeoutMs, { + accountId: account.accountId, + proxyUrl: account.config.proxy, + network: account.config.network, + }), auditAccount: async ({ account, timeoutMs, probe, cfg }) => { const groups = cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ?? @@ -468,6 +468,7 @@ export const telegramPlugin: ChannelPlugin { undiciFetch.mockResolvedValue({ ok: true }); const proxyFetch = makeProxyFetch(proxyUrl); + expect(proxyAgentSpy).not.toHaveBeenCalled(); await proxyFetch("https://api.example.com/v1/audio"); expect(proxyAgentSpy).toHaveBeenCalledWith(proxyUrl); diff --git a/src/infra/net/proxy-fetch.ts b/src/infra/net/proxy-fetch.ts index e6c11813959..391387f3cca 100644 --- a/src/infra/net/proxy-fetch.ts +++ b/src/infra/net/proxy-fetch.ts @@ -1,19 +1,46 @@ import { EnvHttpProxyAgent, ProxyAgent, fetch as undiciFetch } from "undici"; import { logWarn } from "../../logger.js"; +export const PROXY_FETCH_PROXY_URL = Symbol.for("openclaw.proxyFetch.proxyUrl"); +type ProxyFetchWithMetadata = typeof fetch & { + [PROXY_FETCH_PROXY_URL]?: string; +}; + /** * Create a fetch function that routes requests through the given HTTP proxy. * Uses undici's ProxyAgent under the hood. */ export function makeProxyFetch(proxyUrl: string): typeof fetch { - const agent = new ProxyAgent(proxyUrl); + let agent: ProxyAgent | null = null; + const resolveAgent = (): ProxyAgent => { + if (!agent) { + agent = new ProxyAgent(proxyUrl); + } + return agent; + }; // undici's fetch is runtime-compatible with global fetch but the types diverge // on stream/body internals. Single cast at the boundary keeps the rest type-safe. - return ((input: RequestInfo | URL, init?: RequestInit) => + const proxyFetch = ((input: RequestInfo | URL, init?: RequestInit) => undiciFetch(input as string | URL, { ...(init as Record), - dispatcher: agent, - }) as unknown as Promise) as typeof fetch; + dispatcher: resolveAgent(), + }) as unknown as Promise) as ProxyFetchWithMetadata; + Object.defineProperty(proxyFetch, PROXY_FETCH_PROXY_URL, { + value: proxyUrl, + enumerable: false, + configurable: false, + writable: false, + }); + return proxyFetch; +} + +export function getProxyUrlFromFetch(fetchImpl?: typeof fetch): string | undefined { + const proxyUrl = (fetchImpl as ProxyFetchWithMetadata | undefined)?.[PROXY_FETCH_PROXY_URL]; + if (typeof proxyUrl !== "string") { + return undefined; + } + const trimmed = proxyUrl.trim(); + return trimmed ? trimmed : undefined; } /** diff --git a/src/telegram/audit-membership-runtime.ts b/src/telegram/audit-membership-runtime.ts index 4f2c5a43710..c710fb92aa7 100644 --- a/src/telegram/audit-membership-runtime.ts +++ b/src/telegram/audit-membership-runtime.ts @@ -5,6 +5,7 @@ import type { TelegramGroupMembershipAudit, TelegramGroupMembershipAuditEntry, } from "./audit.js"; +import { resolveTelegramFetch } from "./fetch.js"; import { makeProxyFetch } from "./proxy.js"; const TELEGRAM_API_BASE = "https://api.telegram.org"; @@ -16,7 +17,8 @@ type TelegramGroupMembershipAuditData = Omit { - const fetcher = params.proxyUrl ? makeProxyFetch(params.proxyUrl) : fetch; + const proxyFetch = params.proxyUrl ? makeProxyFetch(params.proxyUrl) : undefined; + const fetcher = resolveTelegramFetch(proxyFetch, { network: params.network }); const base = `${TELEGRAM_API_BASE}/bot${params.token}`; const groups: TelegramGroupMembershipAuditEntry[] = []; diff --git a/src/telegram/audit.test.ts b/src/telegram/audit.test.ts index c7524c6ca05..e5cc4490e08 100644 --- a/src/telegram/audit.test.ts +++ b/src/telegram/audit.test.ts @@ -2,16 +2,22 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; let collectTelegramUnmentionedGroupIds: typeof import("./audit.js").collectTelegramUnmentionedGroupIds; let auditTelegramGroupMembership: typeof import("./audit.js").auditTelegramGroupMembership; +const undiciFetch = vi.hoisted(() => vi.fn()); + +vi.mock("undici", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetch: undiciFetch, + }; +}); function mockGetChatMemberStatus(status: string) { - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValueOnce( - new Response(JSON.stringify({ ok: true, result: { status } }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ), + undiciFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true, result: { status } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), ); } @@ -31,7 +37,7 @@ describe("telegram audit", () => { }); beforeEach(() => { - vi.unstubAllGlobals(); + undiciFetch.mockReset(); }); it("collects unmentioned numeric group ids and flags wildcard", async () => { diff --git a/src/telegram/audit.ts b/src/telegram/audit.ts index 24e5f58957a..6b667c37581 100644 --- a/src/telegram/audit.ts +++ b/src/telegram/audit.ts @@ -1,4 +1,5 @@ import type { TelegramGroupConfig } from "../config/types.js"; +import type { TelegramNetworkConfig } from "../config/types.telegram.js"; export type TelegramGroupMembershipAuditEntry = { chatId: string; @@ -64,6 +65,7 @@ export type AuditTelegramGroupMembershipParams = { botId: number; groupIds: string[]; proxyUrl?: string; + network?: TelegramNetworkConfig; timeoutMs: number; }; diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index 78290f342ad..2d1327bcd5f 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -123,6 +123,7 @@ export const registerTelegramHandlers = ({ accountId, bot, opts, + telegramFetchImpl, runtime, mediaMaxBytes, telegramCfg, @@ -371,7 +372,7 @@ export const registerTelegramHandlers = ({ for (const { ctx } of entry.messages) { let media; try { - media = await resolveMedia(ctx, mediaMaxBytes, opts.token, opts.proxyFetch); + media = await resolveMedia(ctx, mediaMaxBytes, opts.token, telegramFetchImpl); } catch (mediaErr) { if (!isRecoverableMediaGroupError(mediaErr)) { throw mediaErr; @@ -475,7 +476,7 @@ export const registerTelegramHandlers = ({ }, mediaMaxBytes, opts.token, - opts.proxyFetch, + telegramFetchImpl, ); if (!media) { return []; @@ -986,7 +987,7 @@ export const registerTelegramHandlers = ({ let media: Awaited> = null; try { - media = await resolveMedia(ctx, mediaMaxBytes, opts.token, opts.proxyFetch); + media = await resolveMedia(ctx, mediaMaxBytes, opts.token, telegramFetchImpl); } catch (mediaErr) { if (isMediaSizeLimitError(mediaErr)) { if (sendOversizeWarning) { diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index aa37c98e9b9..06148b17b33 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -94,6 +94,7 @@ export type RegisterTelegramHandlerParams = { bot: Bot; mediaMaxBytes: number; opts: TelegramBotOptions; + telegramFetchImpl?: typeof fetch; runtime: RuntimeEnv; telegramCfg: TelegramAccountConfig; allowFrom?: Array; diff --git a/src/telegram/bot.media.e2e-harness.ts b/src/telegram/bot.media.e2e-harness.ts index 58628df522b..d26eff44fb6 100644 --- a/src/telegram/bot.media.e2e-harness.ts +++ b/src/telegram/bot.media.e2e-harness.ts @@ -6,6 +6,9 @@ export const middlewareUseSpy: Mock = vi.fn(); export const onSpy: Mock = vi.fn(); export const stopSpy: Mock = vi.fn(); export const sendChatActionSpy: Mock = vi.fn(); +export const undiciFetchSpy: Mock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => + globalThis.fetch(input, init), +); async function defaultSaveMediaBuffer(buffer: Buffer, contentType?: string) { return { @@ -81,6 +84,14 @@ vi.mock("@grammyjs/transformer-throttler", () => ({ apiThrottler: () => throttlerSpy(), })); +vi.mock("undici", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetch: (...args: Parameters) => undiciFetchSpy(...args), + }; +}); + vi.mock("../media/store.js", async (importOriginal) => { const actual = await importOriginal(); const mockModule = Object.create(null) as Record; diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 8bfa0b8ac0c..48d0c745b42 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -439,6 +439,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { accountId: account.accountId, bot, opts, + telegramFetchImpl: fetchImpl as unknown as typeof fetch | undefined, runtime, mediaMaxBytes, telegramCfg, diff --git a/src/telegram/bot/delivery.resolve-media-retry.test.ts b/src/telegram/bot/delivery.resolve-media-retry.test.ts index ce8f50abbbe..df6124343fd 100644 --- a/src/telegram/bot/delivery.resolve-media-retry.test.ts +++ b/src/telegram/bot/delivery.resolve-media-retry.test.ts @@ -293,6 +293,62 @@ describe("resolveMedia getFile retry", () => { expect(getFile).toHaveBeenCalledTimes(3); expect(result).toBeNull(); }); + + it("uses caller-provided fetch impl for file downloads", async () => { + const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" }); + const callerFetch = vi.fn() as unknown as typeof fetch; + fetchRemoteMedia.mockResolvedValueOnce({ + buffer: Buffer.from("pdf-data"), + contentType: "application/pdf", + fileName: "file_42.pdf", + }); + saveMediaBuffer.mockResolvedValueOnce({ + path: "/tmp/file_42---uuid.pdf", + contentType: "application/pdf", + }); + + const result = await resolveMedia( + makeCtx("document", getFile), + MAX_MEDIA_BYTES, + BOT_TOKEN, + callerFetch, + ); + + expect(result).not.toBeNull(); + expect(fetchRemoteMedia).toHaveBeenCalledWith( + expect.objectContaining({ + fetchImpl: callerFetch, + }), + ); + }); + + it("uses caller-provided fetch impl for sticker downloads", async () => { + const getFile = vi.fn().mockResolvedValue({ file_path: "stickers/file_0.webp" }); + const callerFetch = vi.fn() as unknown as typeof fetch; + fetchRemoteMedia.mockResolvedValueOnce({ + buffer: Buffer.from("sticker-data"), + contentType: "image/webp", + fileName: "file_0.webp", + }); + saveMediaBuffer.mockResolvedValueOnce({ + path: "/tmp/file_0.webp", + contentType: "image/webp", + }); + + const result = await resolveMedia( + makeCtx("sticker", getFile), + MAX_MEDIA_BYTES, + BOT_TOKEN, + callerFetch, + ); + + expect(result).not.toBeNull(); + expect(fetchRemoteMedia).toHaveBeenCalledWith( + expect.objectContaining({ + fetchImpl: callerFetch, + }), + ); + }); }); describe("resolveMedia original filename preservation", () => { diff --git a/src/telegram/bot/delivery.resolve-media.ts b/src/telegram/bot/delivery.resolve-media.ts index 14df1d6e2a8..9f560116a5d 100644 --- a/src/telegram/bot/delivery.resolve-media.ts +++ b/src/telegram/bot/delivery.resolve-media.ts @@ -92,12 +92,20 @@ async function resolveTelegramFileWithRetry( } } -function resolveRequiredFetchImpl(proxyFetch?: typeof fetch): typeof fetch { - const fetchImpl = proxyFetch ?? globalThis.fetch; - if (!fetchImpl) { +function resolveRequiredFetchImpl(fetchImpl?: typeof fetch): typeof fetch { + const resolved = fetchImpl ?? globalThis.fetch; + if (!resolved) { throw new Error("fetch is not available; set channels.telegram.proxy in config"); } - return fetchImpl; + return resolved; +} + +function resolveOptionalFetchImpl(fetchImpl?: typeof fetch): typeof fetch | null { + try { + return resolveRequiredFetchImpl(fetchImpl); + } catch { + return null; + } } /** Default idle timeout for Telegram media downloads (30 seconds). */ @@ -134,7 +142,7 @@ async function resolveStickerMedia(params: { ctx: TelegramContext; maxBytes: number; token: string; - proxyFetch?: typeof fetch; + fetchImpl?: typeof fetch; }): Promise< | { path: string; @@ -145,7 +153,7 @@ async function resolveStickerMedia(params: { | null | undefined > { - const { msg, ctx, maxBytes, token, proxyFetch } = params; + const { msg, ctx, maxBytes, token, fetchImpl } = params; if (!msg.sticker) { return undefined; } @@ -165,15 +173,15 @@ async function resolveStickerMedia(params: { logVerbose("telegram: getFile returned no file_path for sticker"); return null; } - const fetchImpl = proxyFetch ?? globalThis.fetch; - if (!fetchImpl) { + const resolvedFetchImpl = resolveOptionalFetchImpl(fetchImpl); + if (!resolvedFetchImpl) { logVerbose("telegram: fetch not available for sticker download"); return null; } const saved = await downloadAndSaveTelegramFile({ filePath: file.file_path, token, - fetchImpl, + fetchImpl: resolvedFetchImpl, maxBytes, }); @@ -229,7 +237,7 @@ export async function resolveMedia( ctx: TelegramContext, maxBytes: number, token: string, - proxyFetch?: typeof fetch, + fetchImpl?: typeof fetch, ): Promise<{ path: string; contentType?: string; @@ -242,7 +250,7 @@ export async function resolveMedia( ctx, maxBytes, token, - proxyFetch, + fetchImpl, }); if (stickerResolved !== undefined) { return stickerResolved; @@ -263,7 +271,7 @@ export async function resolveMedia( const saved = await downloadAndSaveTelegramFile({ filePath: file.file_path, token, - fetchImpl: resolveRequiredFetchImpl(proxyFetch), + fetchImpl: resolveRequiredFetchImpl(fetchImpl), maxBytes, telegramFileName: resolveTelegramFileName(msg), }); diff --git a/src/telegram/fetch.env-proxy-runtime.test.ts b/src/telegram/fetch.env-proxy-runtime.test.ts new file mode 100644 index 00000000000..0292f465747 --- /dev/null +++ b/src/telegram/fetch.env-proxy-runtime.test.ts @@ -0,0 +1,58 @@ +import { createRequire } from "node:module"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const require = createRequire(import.meta.url); +const EnvHttpProxyAgent = require("undici/lib/dispatcher/env-http-proxy-agent.js") as { + new (opts?: Record): Record; +}; +const { kHttpsProxyAgent, kNoProxyAgent } = require("undici/lib/core/symbols.js") as { + kHttpsProxyAgent: symbol; + kNoProxyAgent: symbol; +}; + +function getOwnSymbolValue( + target: Record, + description: string, +): Record | undefined { + const symbol = Object.getOwnPropertySymbols(target).find( + (entry) => entry.description === description, + ); + const value = symbol ? target[symbol] : undefined; + return value && typeof value === "object" ? (value as Record) : undefined; +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("undici env proxy semantics", () => { + it("uses proxyTls rather than connect for proxied HTTPS transport settings", () => { + vi.stubEnv("HTTPS_PROXY", "http://127.0.0.1:7890"); + const connect = { + family: 4, + autoSelectFamily: false, + }; + + const withoutProxyTls = new EnvHttpProxyAgent({ connect }); + const noProxyAgent = withoutProxyTls[kNoProxyAgent] as Record; + const httpsProxyAgent = withoutProxyTls[kHttpsProxyAgent] as Record; + + expect(getOwnSymbolValue(noProxyAgent, "options")?.connect).toEqual( + expect.objectContaining(connect), + ); + expect(getOwnSymbolValue(httpsProxyAgent, "proxy tls settings")).toBeUndefined(); + + const withProxyTls = new EnvHttpProxyAgent({ + connect, + proxyTls: connect, + }); + const httpsProxyAgentWithProxyTls = withProxyTls[kHttpsProxyAgent] as Record< + PropertyKey, + unknown + >; + + expect(getOwnSymbolValue(httpsProxyAgentWithProxyTls, "proxy tls settings")).toEqual( + expect.objectContaining(connect), + ); + }); +}); diff --git a/src/telegram/fetch.test.ts b/src/telegram/fetch.test.ts index 95b26d931cb..dc4c7a5145a 100644 --- a/src/telegram/fetch.test.ts +++ b/src/telegram/fetch.test.ts @@ -1,25 +1,36 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { resolveFetch } from "../infra/fetch.js"; -import { resetTelegramFetchStateForTests, resolveTelegramFetch } from "./fetch.js"; +import { resolveTelegramFetch } from "./fetch.js"; -const setDefaultAutoSelectFamily = vi.hoisted(() => vi.fn()); const setDefaultResultOrder = vi.hoisted(() => vi.fn()); +const setDefaultAutoSelectFamily = vi.hoisted(() => vi.fn()); + +const undiciFetch = vi.hoisted(() => vi.fn()); const setGlobalDispatcher = vi.hoisted(() => vi.fn()); -const getGlobalDispatcherState = vi.hoisted(() => ({ value: undefined as unknown })); -const getGlobalDispatcher = vi.hoisted(() => vi.fn(() => getGlobalDispatcherState.value)); -const EnvHttpProxyAgentCtor = vi.hoisted(() => - vi.fn(function MockEnvHttpProxyAgent(this: { options: unknown }, options: unknown) { +const AgentCtor = vi.hoisted(() => + vi.fn(function MockAgent( + this: { options?: Record }, + options?: Record, + ) { + this.options = options; + }), +); +const EnvHttpProxyAgentCtor = vi.hoisted(() => + vi.fn(function MockEnvHttpProxyAgent( + this: { options?: Record }, + options?: Record, + ) { + this.options = options; + }), +); +const ProxyAgentCtor = vi.hoisted(() => + vi.fn(function MockProxyAgent( + this: { options?: Record | string }, + options?: Record | string, + ) { this.options = options; }), ); - -vi.mock("node:net", async () => { - const actual = await vi.importActual("node:net"); - return { - ...actual, - setDefaultAutoSelectFamily, - }; -}); vi.mock("node:dns", async () => { const actual = await vi.importActual("node:dns"); @@ -29,266 +40,655 @@ vi.mock("node:dns", async () => { }; }); +vi.mock("node:net", async () => { + const actual = await vi.importActual("node:net"); + return { + ...actual, + setDefaultAutoSelectFamily, + }; +}); + vi.mock("undici", () => ({ + Agent: AgentCtor, EnvHttpProxyAgent: EnvHttpProxyAgentCtor, - getGlobalDispatcher, + ProxyAgent: ProxyAgentCtor, + fetch: undiciFetch, setGlobalDispatcher, })); -const originalFetch = globalThis.fetch; - -function expectEnvProxyAgentConstructorCall(params: { nth: number; autoSelectFamily: boolean }) { - expect(EnvHttpProxyAgentCtor).toHaveBeenNthCalledWith(params.nth, { - connect: { - autoSelectFamily: params.autoSelectFamily, - autoSelectFamilyAttemptTimeout: 300, - }, - }); +function resolveTelegramFetchOrThrow( + proxyFetch?: typeof fetch, + options?: { network?: { autoSelectFamily?: boolean; dnsResultOrder?: "ipv4first" | "verbatim" } }, +) { + return resolveTelegramFetch(proxyFetch, options); } -function resolveTelegramFetchOrThrow() { - const resolved = resolveTelegramFetch(); - if (!resolved) { - throw new Error("expected resolved fetch"); +function getDispatcherFromUndiciCall(nth: number) { + const call = undiciFetch.mock.calls[nth - 1] as [RequestInfo | URL, RequestInit?] | undefined; + if (!call) { + throw new Error(`missing undici fetch call #${nth}`); } - return resolved; + const init = call[1] as (RequestInit & { dispatcher?: unknown }) | undefined; + return init?.dispatcher as + | { + options?: { + connect?: Record; + proxyTls?: Record; + }; + } + | undefined; +} + +function buildFetchFallbackError(code: string) { + const connectErr = Object.assign(new Error(`connect ${code} api.telegram.org:443`), { + code, + }); + return Object.assign(new TypeError("fetch failed"), { + cause: connectErr, + }); } afterEach(() => { - resetTelegramFetchStateForTests(); - setDefaultAutoSelectFamily.mockReset(); - setDefaultResultOrder.mockReset(); + undiciFetch.mockReset(); setGlobalDispatcher.mockReset(); - getGlobalDispatcher.mockClear(); - getGlobalDispatcherState.value = undefined; + AgentCtor.mockClear(); EnvHttpProxyAgentCtor.mockClear(); + ProxyAgentCtor.mockClear(); + setDefaultResultOrder.mockReset(); + setDefaultAutoSelectFamily.mockReset(); vi.unstubAllEnvs(); vi.clearAllMocks(); - if (originalFetch) { - globalThis.fetch = originalFetch; - } else { - delete (globalThis as { fetch?: typeof fetch }).fetch; - } }); describe("resolveTelegramFetch", () => { - it("returns wrapped global fetch when available", async () => { - const fetchMock = vi.fn(async () => ({})); - globalThis.fetch = fetchMock as unknown as typeof fetch; + it("wraps proxy fetches and leaves retry policy to caller-provided fetch", async () => { + const proxyFetch = vi.fn(async () => ({ ok: true }) as Response) as unknown as typeof fetch; - const resolved = resolveTelegramFetch(); + const resolved = resolveTelegramFetchOrThrow(proxyFetch); - expect(resolved).toBeTypeOf("function"); - expect(resolved).not.toBe(fetchMock); - }); + await resolved("https://api.telegram.org/botx/getMe"); - it("wraps proxy fetches and normalizes foreign signals once", async () => { - let seenSignal: AbortSignal | undefined; - const proxyFetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { - seenSignal = init?.signal as AbortSignal | undefined; - return {} as Response; - }); - - const resolved = resolveTelegramFetch(proxyFetch as unknown as typeof fetch); - expect(resolved).toBeTypeOf("function"); - - let abortHandler: (() => void) | null = null; - const addEventListener = vi.fn((event: string, handler: () => void) => { - if (event === "abort") { - abortHandler = handler; - } - }); - const removeEventListener = vi.fn((event: string, handler: () => void) => { - if (event === "abort" && abortHandler === handler) { - abortHandler = null; - } - }); - const fakeSignal = { - aborted: false, - addEventListener, - removeEventListener, - } as unknown as AbortSignal; - - if (!resolved) { - throw new Error("expected resolved proxy fetch"); - } - await resolved("https://example.com", { signal: fakeSignal }); - - expect(proxyFetch).toHaveBeenCalledOnce(); - expect(seenSignal).toBeInstanceOf(AbortSignal); - expect(seenSignal).not.toBe(fakeSignal); - expect(addEventListener).toHaveBeenCalledTimes(1); - expect(removeEventListener).toHaveBeenCalledTimes(1); + expect(proxyFetch).toHaveBeenCalledTimes(1); + expect(undiciFetch).not.toHaveBeenCalled(); }); it("does not double-wrap an already wrapped proxy fetch", async () => { const proxyFetch = vi.fn(async () => ({ ok: true }) as Response) as unknown as typeof fetch; - const alreadyWrapped = resolveFetch(proxyFetch); + const wrapped = resolveFetch(proxyFetch); - const resolved = resolveTelegramFetch(alreadyWrapped); + const resolved = resolveTelegramFetch(wrapped); - expect(resolved).toBe(alreadyWrapped); + expect(resolved).toBe(wrapped); }); - it("honors env enable override", async () => { - vi.stubEnv("OPENCLAW_TELEGRAM_ENABLE_AUTO_SELECT_FAMILY", "1"); - globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; - resolveTelegramFetch(); - expect(setDefaultAutoSelectFamily).toHaveBeenCalledWith(true); - }); + it("uses resolver-scoped Agent dispatcher with configured transport policy", async () => { + undiciFetch.mockResolvedValue({ ok: true } as Response); - it("uses config override when provided", async () => { - globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; - resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } }); - expect(setDefaultAutoSelectFamily).toHaveBeenCalledWith(true); - }); - - it("env disable override wins over config", async () => { - vi.stubEnv("OPENCLAW_TELEGRAM_ENABLE_AUTO_SELECT_FAMILY", "0"); - vi.stubEnv("OPENCLAW_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY", "1"); - globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; - resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } }); - expect(setDefaultAutoSelectFamily).toHaveBeenCalledWith(false); - }); - - it("applies dns result order from config", async () => { - globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; - resolveTelegramFetch(undefined, { network: { dnsResultOrder: "verbatim" } }); - expect(setDefaultResultOrder).toHaveBeenCalledWith("verbatim"); - }); - - it("retries dns setter on next call when previous attempt threw", async () => { - setDefaultResultOrder.mockImplementationOnce(() => { - throw new Error("dns setter failed once"); - }); - globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; - - resolveTelegramFetch(undefined, { network: { dnsResultOrder: "ipv4first" } }); - resolveTelegramFetch(undefined, { network: { dnsResultOrder: "ipv4first" } }); - - expect(setDefaultResultOrder).toHaveBeenCalledTimes(2); - }); - - it("replaces global undici dispatcher with proxy-aware EnvHttpProxyAgent", async () => { - globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; - resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } }); - - expect(setGlobalDispatcher).toHaveBeenCalledTimes(1); - expectEnvProxyAgentConstructorCall({ nth: 1, autoSelectFamily: true }); - }); - - it("keeps an existing proxy-like global dispatcher", async () => { - getGlobalDispatcherState.value = { - constructor: { name: "ProxyAgent" }, - }; - globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; - - resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } }); - - expect(setGlobalDispatcher).not.toHaveBeenCalled(); - expect(EnvHttpProxyAgentCtor).not.toHaveBeenCalled(); - }); - - it("updates proxy-like dispatcher when proxy env is configured", async () => { - vi.stubEnv("HTTPS_PROXY", "http://127.0.0.1:7890"); - getGlobalDispatcherState.value = { - constructor: { name: "ProxyAgent" }, - }; - globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; - - resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } }); - - expect(setGlobalDispatcher).toHaveBeenCalledTimes(1); - expect(EnvHttpProxyAgentCtor).toHaveBeenCalledTimes(1); - }); - - it("sets global dispatcher only once across repeated equal decisions", async () => { - globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; - resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } }); - resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } }); - - expect(setGlobalDispatcher).toHaveBeenCalledTimes(1); - }); - - it("updates global dispatcher when autoSelectFamily decision changes", async () => { - globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; - resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } }); - resolveTelegramFetch(undefined, { network: { autoSelectFamily: false } }); - - expect(setGlobalDispatcher).toHaveBeenCalledTimes(2); - expectEnvProxyAgentConstructorCall({ nth: 1, autoSelectFamily: true }); - expectEnvProxyAgentConstructorCall({ nth: 2, autoSelectFamily: false }); - }); - - it("retries once with ipv4 fallback when fetch fails with network timeout/unreachable", async () => { - const timeoutErr = Object.assign(new Error("connect ETIMEDOUT 149.154.166.110:443"), { - code: "ETIMEDOUT", - }); - const unreachableErr = Object.assign( - new Error("connect ENETUNREACH 2001:67c:4e8:f004::9:443"), - { - code: "ENETUNREACH", + const resolved = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: true, + dnsResultOrder: "verbatim", }, - ); - const fetchError = Object.assign(new TypeError("fetch failed"), { - cause: Object.assign(new Error("aggregate"), { - errors: [timeoutErr, unreachableErr], - }), }); - const fetchMock = vi - .fn() - .mockRejectedValueOnce(fetchError) - .mockResolvedValueOnce({ ok: true } as Response); - globalThis.fetch = fetchMock as unknown as typeof fetch; - const resolved = resolveTelegramFetchOrThrow(); + await resolved("https://api.telegram.org/botx/getMe"); - await resolved("https://api.telegram.org/file/botx/photos/file_1.jpg"); + expect(AgentCtor).toHaveBeenCalledTimes(1); + expect(EnvHttpProxyAgentCtor).not.toHaveBeenCalled(); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(setGlobalDispatcher).toHaveBeenCalledTimes(2); - expectEnvProxyAgentConstructorCall({ nth: 1, autoSelectFamily: true }); - expectEnvProxyAgentConstructorCall({ nth: 2, autoSelectFamily: false }); + const dispatcher = getDispatcherFromUndiciCall(1); + expect(dispatcher).toBeDefined(); + expect(dispatcher?.options?.connect).toEqual( + expect.objectContaining({ + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout: 300, + }), + ); + expect(typeof dispatcher?.options?.connect?.lookup).toBe("function"); }); - it("retries with ipv4 fallback once per request, not once per process", async () => { - const timeoutErr = Object.assign(new Error("connect ETIMEDOUT 149.154.166.110:443"), { - code: "ETIMEDOUT", + it("uses EnvHttpProxyAgent dispatcher when proxy env is configured", async () => { + vi.stubEnv("HTTPS_PROXY", "http://127.0.0.1:7890"); + undiciFetch.mockResolvedValue({ ok: true } as Response); + + const resolved = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: false, + dnsResultOrder: "ipv4first", + }, }); - const fetchError = Object.assign(new TypeError("fetch failed"), { - cause: timeoutErr, + + await resolved("https://api.telegram.org/botx/getMe"); + + expect(EnvHttpProxyAgentCtor).toHaveBeenCalledTimes(1); + expect(AgentCtor).not.toHaveBeenCalled(); + + const dispatcher = getDispatcherFromUndiciCall(1); + expect(dispatcher?.options?.connect).toEqual( + expect.objectContaining({ + autoSelectFamily: false, + autoSelectFamilyAttemptTimeout: 300, + }), + ); + expect(dispatcher?.options?.proxyTls).toEqual( + expect.objectContaining({ + autoSelectFamily: false, + autoSelectFamilyAttemptTimeout: 300, + }), + ); + }); + + it("pins env-proxy transport policy onto proxyTls for proxied HTTPS requests", async () => { + vi.stubEnv("HTTPS_PROXY", "http://127.0.0.1:7890"); + undiciFetch.mockResolvedValue({ ok: true } as Response); + + const resolved = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: true, + dnsResultOrder: "ipv4first", + }, }); - const fetchMock = vi - .fn() + + await resolved("https://api.telegram.org/botx/getMe"); + + const dispatcher = getDispatcherFromUndiciCall(1); + expect(dispatcher?.options?.connect).toEqual( + expect.objectContaining({ + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout: 300, + }), + ); + expect(dispatcher?.options?.proxyTls).toEqual( + expect.objectContaining({ + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout: 300, + }), + ); + }); + + it("keeps resolver-scoped transport policy for OpenClaw proxy fetches", async () => { + const { makeProxyFetch } = await import("./proxy.js"); + const proxyFetch = makeProxyFetch("http://127.0.0.1:7890"); + ProxyAgentCtor.mockClear(); + undiciFetch.mockResolvedValue({ ok: true } as Response); + + const resolved = resolveTelegramFetchOrThrow(proxyFetch, { + network: { + autoSelectFamily: false, + dnsResultOrder: "ipv4first", + }, + }); + + await resolved("https://api.telegram.org/botx/getMe"); + + expect(ProxyAgentCtor).toHaveBeenCalledTimes(1); + expect(EnvHttpProxyAgentCtor).not.toHaveBeenCalled(); + expect(AgentCtor).not.toHaveBeenCalled(); + const dispatcher = getDispatcherFromUndiciCall(1); + expect(dispatcher?.options).toEqual( + expect.objectContaining({ + uri: "http://127.0.0.1:7890", + }), + ); + expect(dispatcher?.options?.proxyTls).toEqual( + expect.objectContaining({ + autoSelectFamily: false, + }), + ); + }); + + it("does not blind-retry when sticky IPv4 fallback is disallowed for explicit proxy paths", async () => { + const { makeProxyFetch } = await import("./proxy.js"); + const proxyFetch = makeProxyFetch("http://127.0.0.1:7890"); + ProxyAgentCtor.mockClear(); + const fetchError = buildFetchFallbackError("EHOSTUNREACH"); + undiciFetch.mockRejectedValueOnce(fetchError).mockResolvedValueOnce({ ok: true } as Response); + + const resolved = resolveTelegramFetchOrThrow(proxyFetch, { + network: { + autoSelectFamily: true, + dnsResultOrder: "ipv4first", + }, + }); + + await expect(resolved("https://api.telegram.org/botx/sendMessage")).rejects.toThrow( + "fetch failed", + ); + await resolved("https://api.telegram.org/botx/sendChatAction"); + + expect(undiciFetch).toHaveBeenCalledTimes(2); + expect(ProxyAgentCtor).toHaveBeenCalledTimes(1); + + const firstDispatcher = getDispatcherFromUndiciCall(1); + const secondDispatcher = getDispatcherFromUndiciCall(2); + + expect(firstDispatcher).toBe(secondDispatcher); + expect(firstDispatcher?.options?.proxyTls).toEqual( + expect.objectContaining({ + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout: 300, + }), + ); + expect(firstDispatcher?.options?.proxyTls?.family).not.toBe(4); + }); + + it("does not blind-retry when sticky IPv4 fallback is disallowed for env proxy paths", async () => { + vi.stubEnv("HTTPS_PROXY", "http://127.0.0.1:7890"); + const fetchError = buildFetchFallbackError("EHOSTUNREACH"); + undiciFetch.mockRejectedValueOnce(fetchError).mockResolvedValueOnce({ ok: true } as Response); + + const resolved = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: true, + dnsResultOrder: "ipv4first", + }, + }); + + await expect(resolved("https://api.telegram.org/botx/sendMessage")).rejects.toThrow( + "fetch failed", + ); + await resolved("https://api.telegram.org/botx/sendChatAction"); + + expect(undiciFetch).toHaveBeenCalledTimes(2); + expect(EnvHttpProxyAgentCtor).toHaveBeenCalledTimes(1); + + const firstDispatcher = getDispatcherFromUndiciCall(1); + const secondDispatcher = getDispatcherFromUndiciCall(2); + + expect(firstDispatcher).toBe(secondDispatcher); + expect(firstDispatcher?.options?.connect).toEqual( + expect.objectContaining({ + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout: 300, + }), + ); + expect(firstDispatcher?.options?.connect?.family).not.toBe(4); + }); + + it("treats ALL_PROXY-only env as direct transport and arms sticky IPv4 fallback", async () => { + vi.stubEnv("ALL_PROXY", "socks5://127.0.0.1:1080"); + const fetchError = buildFetchFallbackError("EHOSTUNREACH"); + undiciFetch .mockRejectedValueOnce(fetchError) .mockResolvedValueOnce({ ok: true } as Response) - .mockRejectedValueOnce(fetchError) .mockResolvedValueOnce({ ok: true } as Response); - globalThis.fetch = fetchMock as unknown as typeof fetch; - const resolved = resolveTelegramFetchOrThrow(); + const resolved = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: true, + dnsResultOrder: "ipv4first", + }, + }); - await resolved("https://api.telegram.org/file/botx/photos/file_1.jpg"); - await resolved("https://api.telegram.org/file/botx/photos/file_2.jpg"); + await resolved("https://api.telegram.org/botx/sendMessage"); + await resolved("https://api.telegram.org/botx/sendChatAction"); - expect(fetchMock).toHaveBeenCalledTimes(4); + expect(EnvHttpProxyAgentCtor).not.toHaveBeenCalled(); + expect(AgentCtor).toHaveBeenCalledTimes(2); + + const firstDispatcher = getDispatcherFromUndiciCall(1); + const secondDispatcher = getDispatcherFromUndiciCall(2); + const thirdDispatcher = getDispatcherFromUndiciCall(3); + + expect(firstDispatcher).not.toBe(secondDispatcher); + expect(secondDispatcher).toBe(thirdDispatcher); + expect(secondDispatcher?.options?.connect).toEqual( + expect.objectContaining({ + family: 4, + autoSelectFamily: false, + }), + ); }); - it("does not retry when fetch fails without fallback network error codes", async () => { - const fetchError = Object.assign(new TypeError("fetch failed"), { - cause: Object.assign(new Error("connect ECONNRESET"), { - code: "ECONNRESET", - }), + it("arms sticky IPv4 fallback when env proxy init falls back to direct Agent", async () => { + vi.stubEnv("HTTPS_PROXY", "http://127.0.0.1:7890"); + EnvHttpProxyAgentCtor.mockImplementationOnce(function ThrowingEnvProxyAgent() { + throw new Error("invalid proxy config"); }); - const fetchMock = vi.fn().mockRejectedValue(fetchError); - globalThis.fetch = fetchMock as unknown as typeof fetch; + const fetchError = buildFetchFallbackError("EHOSTUNREACH"); + undiciFetch + .mockRejectedValueOnce(fetchError) + .mockResolvedValueOnce({ ok: true } as Response) + .mockResolvedValueOnce({ ok: true } as Response); - const resolved = resolveTelegramFetchOrThrow(); + const resolved = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: true, + dnsResultOrder: "ipv4first", + }, + }); - await expect(resolved("https://api.telegram.org/file/botx/photos/file_3.jpg")).rejects.toThrow( + await resolved("https://api.telegram.org/botx/sendMessage"); + await resolved("https://api.telegram.org/botx/sendChatAction"); + + expect(undiciFetch).toHaveBeenCalledTimes(3); + expect(EnvHttpProxyAgentCtor).toHaveBeenCalledTimes(1); + expect(AgentCtor).toHaveBeenCalledTimes(2); + + const firstDispatcher = getDispatcherFromUndiciCall(1); + const secondDispatcher = getDispatcherFromUndiciCall(2); + const thirdDispatcher = getDispatcherFromUndiciCall(3); + + expect(firstDispatcher).not.toBe(secondDispatcher); + expect(secondDispatcher).toBe(thirdDispatcher); + expect(secondDispatcher?.options?.connect).toEqual( + expect.objectContaining({ + family: 4, + autoSelectFamily: false, + }), + ); + }); + + it("arms sticky IPv4 fallback when NO_PROXY bypasses telegram under env proxy", async () => { + vi.stubEnv("HTTPS_PROXY", "http://127.0.0.1:7890"); + vi.stubEnv("NO_PROXY", "api.telegram.org"); + const fetchError = buildFetchFallbackError("EHOSTUNREACH"); + undiciFetch + .mockRejectedValueOnce(fetchError) + .mockResolvedValueOnce({ ok: true } as Response) + .mockResolvedValueOnce({ ok: true } as Response); + + const resolved = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: true, + dnsResultOrder: "ipv4first", + }, + }); + + await resolved("https://api.telegram.org/botx/sendMessage"); + await resolved("https://api.telegram.org/botx/sendChatAction"); + + expect(undiciFetch).toHaveBeenCalledTimes(3); + expect(EnvHttpProxyAgentCtor).toHaveBeenCalledTimes(2); + expect(AgentCtor).not.toHaveBeenCalled(); + + const firstDispatcher = getDispatcherFromUndiciCall(1); + const secondDispatcher = getDispatcherFromUndiciCall(2); + const thirdDispatcher = getDispatcherFromUndiciCall(3); + + expect(firstDispatcher).not.toBe(secondDispatcher); + expect(secondDispatcher).toBe(thirdDispatcher); + expect(secondDispatcher?.options?.connect).toEqual( + expect.objectContaining({ + family: 4, + autoSelectFamily: false, + }), + ); + }); + + it("uses no_proxy over NO_PROXY when deciding env-proxy bypass", async () => { + vi.stubEnv("HTTPS_PROXY", "http://127.0.0.1:7890"); + vi.stubEnv("NO_PROXY", ""); + vi.stubEnv("no_proxy", "api.telegram.org"); + const fetchError = buildFetchFallbackError("EHOSTUNREACH"); + undiciFetch + .mockRejectedValueOnce(fetchError) + .mockResolvedValueOnce({ ok: true } as Response) + .mockResolvedValueOnce({ ok: true } as Response); + + const resolved = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: true, + dnsResultOrder: "ipv4first", + }, + }); + + await resolved("https://api.telegram.org/botx/sendMessage"); + await resolved("https://api.telegram.org/botx/sendChatAction"); + + expect(EnvHttpProxyAgentCtor).toHaveBeenCalledTimes(2); + const secondDispatcher = getDispatcherFromUndiciCall(2); + expect(secondDispatcher?.options?.connect).toEqual( + expect.objectContaining({ + family: 4, + autoSelectFamily: false, + }), + ); + }); + + it("matches whitespace and wildcard no_proxy entries like EnvHttpProxyAgent", async () => { + vi.stubEnv("HTTPS_PROXY", "http://127.0.0.1:7890"); + vi.stubEnv("no_proxy", "localhost *.telegram.org"); + const fetchError = buildFetchFallbackError("EHOSTUNREACH"); + undiciFetch + .mockRejectedValueOnce(fetchError) + .mockResolvedValueOnce({ ok: true } as Response) + .mockResolvedValueOnce({ ok: true } as Response); + + const resolved = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: true, + dnsResultOrder: "ipv4first", + }, + }); + + await resolved("https://api.telegram.org/botx/sendMessage"); + await resolved("https://api.telegram.org/botx/sendChatAction"); + + expect(EnvHttpProxyAgentCtor).toHaveBeenCalledTimes(2); + const secondDispatcher = getDispatcherFromUndiciCall(2); + expect(secondDispatcher?.options?.connect).toEqual( + expect.objectContaining({ + family: 4, + autoSelectFamily: false, + }), + ); + }); + + it("fails closed when explicit proxy dispatcher initialization fails", async () => { + const { makeProxyFetch } = await import("./proxy.js"); + const proxyFetch = makeProxyFetch("http://127.0.0.1:7890"); + ProxyAgentCtor.mockClear(); + ProxyAgentCtor.mockImplementationOnce(function ThrowingProxyAgent() { + throw new Error("invalid proxy config"); + }); + + expect(() => + resolveTelegramFetchOrThrow(proxyFetch, { + network: { + autoSelectFamily: true, + dnsResultOrder: "ipv4first", + }, + }), + ).toThrow("explicit proxy dispatcher init failed: invalid proxy config"); + }); + + it("falls back to Agent when env proxy dispatcher initialization fails", async () => { + vi.stubEnv("HTTPS_PROXY", "http://127.0.0.1:7890"); + EnvHttpProxyAgentCtor.mockImplementationOnce(function ThrowingEnvProxyAgent() { + throw new Error("invalid proxy config"); + }); + undiciFetch.mockResolvedValue({ ok: true } as Response); + + const resolved = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: false, + }, + }); + + await resolved("https://api.telegram.org/botx/getMe"); + + expect(EnvHttpProxyAgentCtor).toHaveBeenCalledTimes(1); + expect(AgentCtor).toHaveBeenCalledTimes(1); + + const dispatcher = getDispatcherFromUndiciCall(1); + expect(dispatcher?.options?.connect).toEqual( + expect.objectContaining({ + autoSelectFamily: false, + }), + ); + }); + + it("retries once and then keeps sticky IPv4 dispatcher for subsequent requests", async () => { + const fetchError = buildFetchFallbackError("ETIMEDOUT"); + undiciFetch + .mockRejectedValueOnce(fetchError) + .mockResolvedValueOnce({ ok: true } as Response) + .mockResolvedValueOnce({ ok: true } as Response); + + const resolved = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: true, + }, + }); + + await resolved("https://api.telegram.org/botx/sendMessage"); + await resolved("https://api.telegram.org/botx/sendChatAction"); + + expect(undiciFetch).toHaveBeenCalledTimes(3); + + const firstDispatcher = getDispatcherFromUndiciCall(1); + const secondDispatcher = getDispatcherFromUndiciCall(2); + const thirdDispatcher = getDispatcherFromUndiciCall(3); + + expect(firstDispatcher).toBeDefined(); + expect(secondDispatcher).toBeDefined(); + expect(thirdDispatcher).toBeDefined(); + + expect(firstDispatcher).not.toBe(secondDispatcher); + expect(secondDispatcher).toBe(thirdDispatcher); + + expect(firstDispatcher?.options?.connect).toEqual( + expect.objectContaining({ + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout: 300, + }), + ); + expect(secondDispatcher?.options?.connect).toEqual( + expect.objectContaining({ + family: 4, + autoSelectFamily: false, + }), + ); + }); + + it("preserves caller-provided dispatcher across fallback retry", async () => { + const fetchError = buildFetchFallbackError("EHOSTUNREACH"); + undiciFetch.mockRejectedValueOnce(fetchError).mockResolvedValueOnce({ ok: true } as Response); + + const resolved = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: true, + }, + }); + + const callerDispatcher = { name: "caller" }; + + await resolved("https://api.telegram.org/botx/sendMessage", { + dispatcher: callerDispatcher, + } as RequestInit); + + expect(undiciFetch).toHaveBeenCalledTimes(2); + + const firstCallInit = undiciFetch.mock.calls[0]?.[1] as + | (RequestInit & { dispatcher?: unknown }) + | undefined; + const secondCallInit = undiciFetch.mock.calls[1]?.[1] as + | (RequestInit & { dispatcher?: unknown }) + | undefined; + + expect(firstCallInit?.dispatcher).toBe(callerDispatcher); + expect(secondCallInit?.dispatcher).toBe(callerDispatcher); + }); + + it("does not arm sticky fallback from caller-provided dispatcher failures", async () => { + const fetchError = buildFetchFallbackError("EHOSTUNREACH"); + undiciFetch + .mockRejectedValueOnce(fetchError) + .mockResolvedValueOnce({ ok: true } as Response) + .mockResolvedValueOnce({ ok: true } as Response); + + const resolved = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: true, + }, + }); + + const callerDispatcher = { name: "caller" }; + + await resolved("https://api.telegram.org/botx/sendMessage", { + dispatcher: callerDispatcher, + } as RequestInit); + await resolved("https://api.telegram.org/botx/sendChatAction"); + + expect(undiciFetch).toHaveBeenCalledTimes(3); + + const firstCallInit = undiciFetch.mock.calls[0]?.[1] as + | (RequestInit & { dispatcher?: unknown }) + | undefined; + const secondCallInit = undiciFetch.mock.calls[1]?.[1] as + | (RequestInit & { dispatcher?: unknown }) + | undefined; + const thirdDispatcher = getDispatcherFromUndiciCall(3); + + expect(firstCallInit?.dispatcher).toBe(callerDispatcher); + expect(secondCallInit?.dispatcher).toBe(callerDispatcher); + expect(thirdDispatcher?.options?.connect).toEqual( + expect.objectContaining({ + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout: 300, + }), + ); + expect(thirdDispatcher?.options?.connect?.family).not.toBe(4); + }); + + it("does not retry when error codes do not match fallback rules", async () => { + const fetchError = buildFetchFallbackError("ECONNRESET"); + undiciFetch.mockRejectedValue(fetchError); + + const resolved = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: true, + }, + }); + + await expect(resolved("https://api.telegram.org/botx/sendMessage")).rejects.toThrow( "fetch failed", ); - expect(fetchMock).toHaveBeenCalledTimes(1); + expect(undiciFetch).toHaveBeenCalledTimes(1); + }); + + it("keeps per-resolver transport policy isolated across multiple accounts", async () => { + undiciFetch.mockResolvedValue({ ok: true } as Response); + + const resolverA = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: false, + dnsResultOrder: "ipv4first", + }, + }); + const resolverB = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: true, + dnsResultOrder: "verbatim", + }, + }); + + await resolverA("https://api.telegram.org/botA/getMe"); + await resolverB("https://api.telegram.org/botB/getMe"); + + const dispatcherA = getDispatcherFromUndiciCall(1); + const dispatcherB = getDispatcherFromUndiciCall(2); + + expect(dispatcherA).toBeDefined(); + expect(dispatcherB).toBeDefined(); + expect(dispatcherA).not.toBe(dispatcherB); + + expect(dispatcherA?.options?.connect).toEqual( + expect.objectContaining({ + autoSelectFamily: false, + }), + ); + expect(dispatcherB?.options?.connect).toEqual( + expect.objectContaining({ + autoSelectFamily: true, + }), + ); + + // Core guarantee: Telegram transport no longer mutates process-global defaults. + expect(setGlobalDispatcher).not.toHaveBeenCalled(); + expect(setDefaultResultOrder).not.toHaveBeenCalled(); + expect(setDefaultAutoSelectFamily).not.toHaveBeenCalled(); }); }); diff --git a/src/telegram/fetch.ts b/src/telegram/fetch.ts index f1e50021e92..3934c10c391 100644 --- a/src/telegram/fetch.ts +++ b/src/telegram/fetch.ts @@ -1,23 +1,43 @@ import * as dns from "node:dns"; -import * as net from "node:net"; -import { EnvHttpProxyAgent, getGlobalDispatcher, setGlobalDispatcher } from "undici"; +import { Agent, EnvHttpProxyAgent, ProxyAgent, fetch as undiciFetch } from "undici"; import type { TelegramNetworkConfig } from "../config/types.telegram.js"; import { resolveFetch } from "../infra/fetch.js"; -import { hasProxyEnvConfigured } from "../infra/net/proxy-env.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveTelegramAutoSelectFamilyDecision, resolveTelegramDnsResultOrderDecision, } from "./network-config.js"; +import { getProxyUrlFromFetch } from "./proxy.js"; -let appliedAutoSelectFamily: boolean | null = null; -let appliedDnsResultOrder: string | null = null; -let appliedGlobalDispatcherAutoSelectFamily: boolean | null = null; const log = createSubsystemLogger("telegram/network"); -function isProxyLikeDispatcher(dispatcher: unknown): boolean { - const ctorName = (dispatcher as { constructor?: { name?: string } })?.constructor?.name; - return typeof ctorName === "string" && ctorName.includes("ProxyAgent"); -} + +const TELEGRAM_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS = 300; +const TELEGRAM_API_HOSTNAME = "api.telegram.org"; + +type RequestInitWithDispatcher = RequestInit & { + dispatcher?: unknown; +}; + +type TelegramDispatcher = Agent | EnvHttpProxyAgent | ProxyAgent; + +type TelegramDispatcherMode = "direct" | "env-proxy" | "explicit-proxy"; + +type TelegramDnsResultOrder = "ipv4first" | "verbatim"; + +type LookupCallback = + | ((err: NodeJS.ErrnoException | null, address: string, family: number) => void) + | ((err: NodeJS.ErrnoException | null, addresses: dns.LookupAddress[]) => void); + +type LookupOptions = (dns.LookupOneOptions | dns.LookupAllOptions) & { + order?: TelegramDnsResultOrder; + verbatim?: boolean; +}; + +type LookupFunction = ( + hostname: string, + options: number | dns.LookupOneOptions | dns.LookupAllOptions | undefined, + callback: LookupCallback, +) => void; const FALLBACK_RETRY_ERROR_CODES = [ "ETIMEDOUT", @@ -48,72 +68,215 @@ const IPV4_FALLBACK_RULES: readonly Ipv4FallbackRule[] = [ }, ]; -// Node 22 workaround: enable autoSelectFamily to allow IPv4 fallback on broken IPv6 networks. -// Many networks have IPv6 configured but not routed, causing "Network is unreachable" errors. -// See: https://github.com/nodejs/node/issues/54359 -function applyTelegramNetworkWorkarounds(network?: TelegramNetworkConfig): void { - // Apply autoSelectFamily workaround - const autoSelectDecision = resolveTelegramAutoSelectFamilyDecision({ network }); - if (autoSelectDecision.value !== null && autoSelectDecision.value !== appliedAutoSelectFamily) { - if (typeof net.setDefaultAutoSelectFamily === "function") { - try { - net.setDefaultAutoSelectFamily(autoSelectDecision.value); - appliedAutoSelectFamily = autoSelectDecision.value; - const label = autoSelectDecision.source ? ` (${autoSelectDecision.source})` : ""; - log.info(`autoSelectFamily=${autoSelectDecision.value}${label}`); - } catch { - // ignore if unsupported by the runtime - } - } +function normalizeDnsResultOrder(value: string | null): TelegramDnsResultOrder | null { + if (value === "ipv4first" || value === "verbatim") { + return value; + } + return null; +} + +function createDnsResultOrderLookup( + order: TelegramDnsResultOrder | null, +): LookupFunction | undefined { + if (!order) { + return undefined; + } + const lookup = dns.lookup as unknown as ( + hostname: string, + options: LookupOptions, + callback: LookupCallback, + ) => void; + return (hostname, options, callback) => { + const baseOptions: LookupOptions = + typeof options === "number" + ? { family: options } + : options + ? { ...(options as LookupOptions) } + : {}; + const lookupOptions: LookupOptions = { + ...baseOptions, + order, + // Keep `verbatim` for compatibility with Node runtimes that ignore `order`. + verbatim: order === "verbatim", + }; + lookup(hostname, lookupOptions, callback); + }; +} + +function buildTelegramConnectOptions(params: { + autoSelectFamily: boolean | null; + dnsResultOrder: TelegramDnsResultOrder | null; + forceIpv4: boolean; +}): { + autoSelectFamily?: boolean; + autoSelectFamilyAttemptTimeout?: number; + family?: number; + lookup?: LookupFunction; +} | null { + const connect: { + autoSelectFamily?: boolean; + autoSelectFamilyAttemptTimeout?: number; + family?: number; + lookup?: LookupFunction; + } = {}; + + if (params.forceIpv4) { + connect.family = 4; + connect.autoSelectFamily = false; + } else if (typeof params.autoSelectFamily === "boolean") { + connect.autoSelectFamily = params.autoSelectFamily; + connect.autoSelectFamilyAttemptTimeout = TELEGRAM_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS; } - // Node 22's built-in globalThis.fetch uses undici's internal Agent whose - // connect options are frozen at construction time. Calling - // net.setDefaultAutoSelectFamily() after that agent is created has no - // effect on it. Replace the global dispatcher with one that carries the - // current autoSelectFamily setting so subsequent globalThis.fetch calls - // inherit the same decision. - // See: https://github.com/openclaw/openclaw/issues/25676 - if ( - autoSelectDecision.value !== null && - autoSelectDecision.value !== appliedGlobalDispatcherAutoSelectFamily - ) { - const existingGlobalDispatcher = getGlobalDispatcher(); - const shouldPreserveExistingProxy = - isProxyLikeDispatcher(existingGlobalDispatcher) && !hasProxyEnvConfigured(); - if (!shouldPreserveExistingProxy) { - try { - setGlobalDispatcher( - new EnvHttpProxyAgent({ - connect: { - autoSelectFamily: autoSelectDecision.value, - autoSelectFamilyAttemptTimeout: 300, - }, - }), - ); - appliedGlobalDispatcherAutoSelectFamily = autoSelectDecision.value; - log.info(`global undici dispatcher autoSelectFamily=${autoSelectDecision.value}`); - } catch { - // ignore if setGlobalDispatcher is unavailable - } - } + const lookup = createDnsResultOrderLookup(params.dnsResultOrder); + if (lookup) { + connect.lookup = lookup; } - // Apply DNS result order workaround for IPv4/IPv6 issues. - // Some APIs (including Telegram) may fail with IPv6 on certain networks. - // See: https://github.com/openclaw/openclaw/issues/5311 - const dnsDecision = resolveTelegramDnsResultOrderDecision({ network }); - if (dnsDecision.value !== null && dnsDecision.value !== appliedDnsResultOrder) { - if (typeof dns.setDefaultResultOrder === "function") { - try { - dns.setDefaultResultOrder(dnsDecision.value as "ipv4first" | "verbatim"); - appliedDnsResultOrder = dnsDecision.value; - const label = dnsDecision.source ? ` (${dnsDecision.source})` : ""; - log.info(`dnsResultOrder=${dnsDecision.value}${label}`); - } catch { - // ignore if unsupported by the runtime - } + return Object.keys(connect).length > 0 ? connect : null; +} + +function shouldBypassEnvProxyForTelegramApi(env: NodeJS.ProcessEnv = process.env): boolean { + // We need this classification before dispatch to decide whether sticky IPv4 fallback + // can safely arm. EnvHttpProxyAgent does not expose route decisions (proxy vs direct + // NO_PROXY bypass), so we mirror undici's parsing/matching behavior for this host. + // Match EnvHttpProxyAgent behavior (undici): + // - lower-case no_proxy takes precedence over NO_PROXY + // - entries split by comma or whitespace + // - wildcard handling is exact-string "*" only + // - leading "." and "*." are normalized the same way + const noProxyValue = env.no_proxy ?? env.NO_PROXY ?? ""; + if (!noProxyValue) { + return false; + } + if (noProxyValue === "*") { + return true; + } + const targetHostname = TELEGRAM_API_HOSTNAME.toLowerCase(); + const targetPort = 443; + const noProxyEntries = noProxyValue.split(/[,\s]/); + for (let i = 0; i < noProxyEntries.length; i++) { + const entry = noProxyEntries[i]; + if (!entry) { + continue; } + const parsed = entry.match(/^(.+):(\d+)$/); + const entryHostname = (parsed ? parsed[1] : entry).replace(/^\*?\./, "").toLowerCase(); + const entryPort = parsed ? Number.parseInt(parsed[2], 10) : 0; + if (entryPort && entryPort !== targetPort) { + continue; + } + if ( + targetHostname === entryHostname || + targetHostname.slice(-(entryHostname.length + 1)) === `.${entryHostname}` + ) { + return true; + } + } + return false; +} + +function hasEnvHttpProxyForTelegramApi(env: NodeJS.ProcessEnv = process.env): boolean { + // Match EnvHttpProxyAgent behavior (undici) for HTTPS requests: + // - lower-case env vars take precedence over upper-case + // - HTTPS requests use https_proxy/HTTPS_PROXY first, then fall back to http_proxy/HTTP_PROXY + // - ALL_PROXY is ignored by EnvHttpProxyAgent + const httpProxy = env.http_proxy ?? env.HTTP_PROXY; + const httpsProxy = env.https_proxy ?? env.HTTPS_PROXY; + return Boolean(httpProxy) || Boolean(httpsProxy); +} + +function createTelegramDispatcher(params: { + autoSelectFamily: boolean | null; + dnsResultOrder: TelegramDnsResultOrder | null; + useEnvProxy: boolean; + forceIpv4: boolean; + proxyUrl?: string; +}): { dispatcher: TelegramDispatcher; mode: TelegramDispatcherMode } { + const connect = buildTelegramConnectOptions({ + autoSelectFamily: params.autoSelectFamily, + dnsResultOrder: params.dnsResultOrder, + forceIpv4: params.forceIpv4, + }); + const explicitProxyUrl = params.proxyUrl?.trim(); + if (explicitProxyUrl) { + const proxyOptions = connect + ? ({ + uri: explicitProxyUrl, + proxyTls: connect, + } satisfies ConstructorParameters[0]) + : explicitProxyUrl; + try { + return { + dispatcher: new ProxyAgent(proxyOptions), + mode: "explicit-proxy", + }; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw new Error(`explicit proxy dispatcher init failed: ${reason}`, { cause: err }); + } + } + if (params.useEnvProxy) { + const proxyOptions = connect + ? ({ + connect, + // undici's EnvHttpProxyAgent passes `connect` only to the no-proxy Agent. + // Real proxied HTTPS traffic reads transport settings from ProxyAgent.proxyTls. + proxyTls: connect, + } satisfies ConstructorParameters[0]) + : undefined; + try { + return { + dispatcher: new EnvHttpProxyAgent(proxyOptions), + mode: "env-proxy", + }; + } catch (err) { + log.warn( + `env proxy dispatcher init failed; falling back to direct dispatcher: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + const agentOptions = connect + ? ({ + connect, + } satisfies ConstructorParameters[0]) + : undefined; + return { + dispatcher: new Agent(agentOptions), + mode: "direct", + }; +} + +function withDispatcherIfMissing( + init: RequestInit | undefined, + dispatcher: TelegramDispatcher, +): RequestInitWithDispatcher { + const withDispatcher = init as RequestInitWithDispatcher | undefined; + if (withDispatcher?.dispatcher) { + return init ?? {}; + } + return init ? { ...init, dispatcher } : { dispatcher }; +} + +function resolveWrappedFetch(fetchImpl: typeof fetch): typeof fetch { + return resolveFetch(fetchImpl) ?? fetchImpl; +} + +function logResolverNetworkDecisions(params: { + autoSelectDecision: ReturnType; + dnsDecision: ReturnType; +}): void { + if (params.autoSelectDecision.value !== null) { + const sourceLabel = params.autoSelectDecision.source + ? ` (${params.autoSelectDecision.source})` + : ""; + log.info(`autoSelectFamily=${params.autoSelectDecision.value}${sourceLabel}`); + } + if (params.dnsDecision.value !== null) { + const sourceLabel = params.dnsDecision.source ? ` (${params.dnsDecision.source})` : ""; + log.info(`dnsResultOrder=${params.dnsDecision.value}${sourceLabel}`); } } @@ -151,6 +314,11 @@ function collectErrorCodes(err: unknown): Set { return codes; } +function formatErrorCodes(err: unknown): string { + const codes = [...collectErrorCodes(err)]; + return codes.length > 0 ? codes.join(",") : "none"; +} + function shouldRetryWithIpv4Fallback(err: unknown): boolean { const ctx: Ipv4FallbackContext = { message: @@ -165,44 +333,97 @@ function shouldRetryWithIpv4Fallback(err: unknown): boolean { return true; } -function applyTelegramIpv4Fallback(): void { - applyTelegramNetworkWorkarounds({ - autoSelectFamily: false, - dnsResultOrder: "ipv4first", - }); - log.warn("fetch fallback: forcing autoSelectFamily=false + dnsResultOrder=ipv4first"); -} - // Prefer wrapped fetch when available to normalize AbortSignal across runtimes. export function resolveTelegramFetch( proxyFetch?: typeof fetch, options?: { network?: TelegramNetworkConfig }, -): typeof fetch | undefined { - applyTelegramNetworkWorkarounds(options?.network); - const sourceFetch = proxyFetch ? resolveFetch(proxyFetch) : resolveFetch(); - if (!sourceFetch) { - throw new Error("fetch is not available; set channels.telegram.proxy in config"); - } - // When Telegram media fetch hits dual-stack edge cases (ENETUNREACH/ETIMEDOUT), - // switch to IPv4-safe network mode and retry once. - if (proxyFetch) { +): typeof fetch { + const autoSelectDecision = resolveTelegramAutoSelectFamilyDecision({ + network: options?.network, + }); + const dnsDecision = resolveTelegramDnsResultOrderDecision({ + network: options?.network, + }); + logResolverNetworkDecisions({ + autoSelectDecision, + dnsDecision, + }); + + const explicitProxyUrl = proxyFetch ? getProxyUrlFromFetch(proxyFetch) : undefined; + const undiciSourceFetch = resolveWrappedFetch(undiciFetch as unknown as typeof fetch); + const sourceFetch = explicitProxyUrl + ? undiciSourceFetch + : proxyFetch + ? resolveWrappedFetch(proxyFetch) + : undiciSourceFetch; + + // Preserve fully caller-owned custom fetch implementations. + // OpenClaw proxy fetches are metadata-tagged and continue into resolver-scoped policy. + if (proxyFetch && !explicitProxyUrl) { return sourceFetch; } + + const dnsResultOrder = normalizeDnsResultOrder(dnsDecision.value); + const useEnvProxy = !explicitProxyUrl && hasEnvHttpProxyForTelegramApi(); + const defaultDispatcherResolution = createTelegramDispatcher({ + autoSelectFamily: autoSelectDecision.value, + dnsResultOrder, + useEnvProxy, + forceIpv4: false, + proxyUrl: explicitProxyUrl, + }); + const defaultDispatcher = defaultDispatcherResolution.dispatcher; + const shouldBypassEnvProxy = shouldBypassEnvProxyForTelegramApi(); + const allowStickyIpv4Fallback = + defaultDispatcherResolution.mode === "direct" || + (defaultDispatcherResolution.mode === "env-proxy" && shouldBypassEnvProxy); + const stickyShouldUseEnvProxy = defaultDispatcherResolution.mode === "env-proxy"; + + let stickyIpv4FallbackEnabled = false; + let stickyIpv4Dispatcher: TelegramDispatcher | null = null; + const resolveStickyIpv4Dispatcher = () => { + if (!stickyIpv4Dispatcher) { + stickyIpv4Dispatcher = createTelegramDispatcher({ + autoSelectFamily: false, + dnsResultOrder: "ipv4first", + useEnvProxy: stickyShouldUseEnvProxy, + forceIpv4: true, + proxyUrl: explicitProxyUrl, + }).dispatcher; + } + return stickyIpv4Dispatcher; + }; + return (async (input: RequestInfo | URL, init?: RequestInit) => { + const callerProvidedDispatcher = Boolean( + (init as RequestInitWithDispatcher | undefined)?.dispatcher, + ); + const initialInit = withDispatcherIfMissing( + init, + stickyIpv4FallbackEnabled ? resolveStickyIpv4Dispatcher() : defaultDispatcher, + ); try { - return await sourceFetch(input, init); + return await sourceFetch(input, initialInit); } catch (err) { if (shouldRetryWithIpv4Fallback(err)) { - applyTelegramIpv4Fallback(); - return sourceFetch(input, init); + // Preserve caller-owned dispatchers on retry. + if (callerProvidedDispatcher) { + return sourceFetch(input, init ?? {}); + } + // Proxy routes should not arm sticky IPv4 mode; `family=4` would constrain + // proxy-connect behavior instead of Telegram endpoint selection. + if (!allowStickyIpv4Fallback) { + throw err; + } + if (!stickyIpv4FallbackEnabled) { + stickyIpv4FallbackEnabled = true; + log.warn( + `fetch fallback: enabling sticky IPv4-only dispatcher (codes=${formatErrorCodes(err)})`, + ); + } + return sourceFetch(input, withDispatcherIfMissing(init, resolveStickyIpv4Dispatcher())); } throw err; } }) as typeof fetch; } - -export function resetTelegramFetchStateForTests(): void { - appliedAutoSelectFamily = null; - appliedDnsResultOrder = null; - appliedGlobalDispatcherAutoSelectFamily = null; -} diff --git a/src/telegram/probe.test.ts b/src/telegram/probe.test.ts index 11b0b317eec..7006d14a2f7 100644 --- a/src/telegram/probe.test.ts +++ b/src/telegram/probe.test.ts @@ -1,14 +1,28 @@ -import { type Mock, describe, expect, it, vi } from "vitest"; +import { afterEach, type Mock, describe, expect, it, vi } from "vitest"; import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; -import { probeTelegram } from "./probe.js"; +import { probeTelegram, resetTelegramProbeFetcherCacheForTests } from "./probe.js"; + +const resolveTelegramFetch = vi.hoisted(() => vi.fn()); +const makeProxyFetch = vi.hoisted(() => vi.fn()); + +vi.mock("./fetch.js", () => ({ + resolveTelegramFetch, +})); + +vi.mock("./proxy.js", () => ({ + makeProxyFetch, +})); describe("probeTelegram retry logic", () => { const token = "test-token"; const timeoutMs = 5000; + const originalFetch = global.fetch; const installFetchMock = (): Mock => { const fetchMock = vi.fn(); global.fetch = withFetchPreconnect(fetchMock); + resolveTelegramFetch.mockImplementation((proxyFetch?: typeof fetch) => proxyFetch ?? fetch); + makeProxyFetch.mockImplementation(() => fetchMock as unknown as typeof fetch); return fetchMock; }; @@ -41,6 +55,19 @@ describe("probeTelegram retry logic", () => { expect(result.bot?.username).toBe("test_bot"); } + afterEach(() => { + resetTelegramProbeFetcherCacheForTests(); + resolveTelegramFetch.mockReset(); + makeProxyFetch.mockReset(); + vi.unstubAllEnvs(); + vi.clearAllMocks(); + if (originalFetch) { + global.fetch = originalFetch; + } else { + delete (globalThis as { fetch?: typeof fetch }).fetch; + } + }); + it.each([ { errors: [], @@ -95,6 +122,35 @@ describe("probeTelegram retry logic", () => { } }); + it("respects timeout budget across retries", async () => { + const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => { + return new Promise((_resolve, reject) => { + const signal = init?.signal; + if (signal?.aborted) { + reject(new Error("Request aborted")); + return; + } + signal?.addEventListener("abort", () => reject(new Error("Request aborted")), { + once: true, + }); + }); + }); + global.fetch = withFetchPreconnect(fetchMock as unknown as typeof fetch); + resolveTelegramFetch.mockImplementation((proxyFetch?: typeof fetch) => proxyFetch ?? fetch); + makeProxyFetch.mockImplementation(() => fetchMock as unknown as typeof fetch); + vi.useFakeTimers(); + try { + const probePromise = probeTelegram(`${token}-budget`, 500); + await vi.advanceTimersByTimeAsync(600); + const result = await probePromise; + + expect(result.ok).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + it("should NOT retry if getMe returns a 401 Unauthorized", async () => { const fetchMock = installFetchMock(); const mockResponse = { @@ -114,4 +170,106 @@ describe("probeTelegram retry logic", () => { expect(result.error).toBe("Unauthorized"); expect(fetchMock).toHaveBeenCalledTimes(1); // Should not retry }); + + it("uses resolver-scoped Telegram fetch with probe network options", async () => { + const fetchMock = installFetchMock(); + mockGetMeSuccess(fetchMock); + mockGetWebhookInfoSuccess(fetchMock); + + await probeTelegram(token, timeoutMs, { + proxyUrl: "http://127.0.0.1:8888", + network: { + autoSelectFamily: false, + dnsResultOrder: "ipv4first", + }, + }); + + expect(makeProxyFetch).toHaveBeenCalledWith("http://127.0.0.1:8888"); + expect(resolveTelegramFetch).toHaveBeenCalledWith(fetchMock, { + network: { + autoSelectFamily: false, + dnsResultOrder: "ipv4first", + }, + }); + }); + + it("reuses probe fetcher across repeated probes for the same account transport settings", async () => { + const fetchMock = installFetchMock(); + vi.stubEnv("VITEST", ""); + vi.stubEnv("NODE_ENV", "production"); + + mockGetMeSuccess(fetchMock); + mockGetWebhookInfoSuccess(fetchMock); + await probeTelegram(`${token}-cache`, timeoutMs, { + network: { + autoSelectFamily: true, + dnsResultOrder: "ipv4first", + }, + }); + + mockGetMeSuccess(fetchMock); + mockGetWebhookInfoSuccess(fetchMock); + await probeTelegram(`${token}-cache`, timeoutMs, { + network: { + autoSelectFamily: true, + dnsResultOrder: "ipv4first", + }, + }); + + expect(resolveTelegramFetch).toHaveBeenCalledTimes(1); + }); + + it("does not reuse probe fetcher cache when network settings differ", async () => { + const fetchMock = installFetchMock(); + vi.stubEnv("VITEST", ""); + vi.stubEnv("NODE_ENV", "production"); + + mockGetMeSuccess(fetchMock); + mockGetWebhookInfoSuccess(fetchMock); + await probeTelegram(`${token}-cache-variant`, timeoutMs, { + network: { + autoSelectFamily: true, + dnsResultOrder: "ipv4first", + }, + }); + + mockGetMeSuccess(fetchMock); + mockGetWebhookInfoSuccess(fetchMock); + await probeTelegram(`${token}-cache-variant`, timeoutMs, { + network: { + autoSelectFamily: false, + dnsResultOrder: "ipv4first", + }, + }); + + expect(resolveTelegramFetch).toHaveBeenCalledTimes(2); + }); + + it("reuses probe fetcher cache across token rotation when accountId is stable", async () => { + const fetchMock = installFetchMock(); + vi.stubEnv("VITEST", ""); + vi.stubEnv("NODE_ENV", "production"); + + mockGetMeSuccess(fetchMock); + mockGetWebhookInfoSuccess(fetchMock); + await probeTelegram(`${token}-old`, timeoutMs, { + accountId: "main", + network: { + autoSelectFamily: true, + dnsResultOrder: "ipv4first", + }, + }); + + mockGetMeSuccess(fetchMock); + mockGetWebhookInfoSuccess(fetchMock); + await probeTelegram(`${token}-new`, timeoutMs, { + accountId: "main", + network: { + autoSelectFamily: true, + dnsResultOrder: "ipv4first", + }, + }); + + expect(resolveTelegramFetch).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/telegram/probe.ts b/src/telegram/probe.ts index f988733f0ee..8311506e455 100644 --- a/src/telegram/probe.ts +++ b/src/telegram/probe.ts @@ -1,5 +1,7 @@ import type { BaseProbeResult } from "../channels/plugins/types.js"; +import type { TelegramNetworkConfig } from "../config/types.telegram.js"; import { fetchWithTimeout } from "../utils/fetch-timeout.js"; +import { resolveTelegramFetch } from "./fetch.js"; import { makeProxyFetch } from "./proxy.js"; const TELEGRAM_API_BASE = "https://api.telegram.org"; @@ -17,15 +19,90 @@ export type TelegramProbe = BaseProbeResult & { webhook?: { url?: string | null; hasCustomCert?: boolean | null }; }; +export type TelegramProbeOptions = { + proxyUrl?: string; + network?: TelegramNetworkConfig; + accountId?: string; +}; + +const probeFetcherCache = new Map(); +const MAX_PROBE_FETCHER_CACHE_SIZE = 64; + +export function resetTelegramProbeFetcherCacheForTests(): void { + probeFetcherCache.clear(); +} + +function resolveProbeOptions( + proxyOrOptions?: string | TelegramProbeOptions, +): TelegramProbeOptions | undefined { + if (!proxyOrOptions) { + return undefined; + } + if (typeof proxyOrOptions === "string") { + return { proxyUrl: proxyOrOptions }; + } + return proxyOrOptions; +} + +function shouldUseProbeFetcherCache(): boolean { + return !process.env.VITEST && process.env.NODE_ENV !== "test"; +} + +function buildProbeFetcherCacheKey(token: string, options?: TelegramProbeOptions): string { + const cacheIdentity = options?.accountId?.trim() || token; + const cacheIdentityKind = options?.accountId?.trim() ? "account" : "token"; + const proxyKey = options?.proxyUrl?.trim() ?? ""; + const autoSelectFamily = options?.network?.autoSelectFamily; + const autoSelectFamilyKey = + typeof autoSelectFamily === "boolean" ? String(autoSelectFamily) : "default"; + const dnsResultOrderKey = options?.network?.dnsResultOrder ?? "default"; + return `${cacheIdentityKind}:${cacheIdentity}::${proxyKey}::${autoSelectFamilyKey}::${dnsResultOrderKey}`; +} + +function setCachedProbeFetcher(cacheKey: string, fetcher: typeof fetch): typeof fetch { + probeFetcherCache.set(cacheKey, fetcher); + if (probeFetcherCache.size > MAX_PROBE_FETCHER_CACHE_SIZE) { + const oldestKey = probeFetcherCache.keys().next().value; + if (oldestKey !== undefined) { + probeFetcherCache.delete(oldestKey); + } + } + return fetcher; +} + +function resolveProbeFetcher(token: string, options?: TelegramProbeOptions): typeof fetch { + const cacheEnabled = shouldUseProbeFetcherCache(); + const cacheKey = cacheEnabled ? buildProbeFetcherCacheKey(token, options) : null; + if (cacheKey) { + const cachedFetcher = probeFetcherCache.get(cacheKey); + if (cachedFetcher) { + return cachedFetcher; + } + } + + const proxyUrl = options?.proxyUrl?.trim(); + const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined; + const resolved = resolveTelegramFetch(proxyFetch, { network: options?.network }); + + if (cacheKey) { + return setCachedProbeFetcher(cacheKey, resolved); + } + return resolved; +} + export async function probeTelegram( token: string, timeoutMs: number, - proxyUrl?: string, + proxyOrOptions?: string | TelegramProbeOptions, ): Promise { const started = Date.now(); - const fetcher = proxyUrl ? makeProxyFetch(proxyUrl) : fetch; + const timeoutBudgetMs = Math.max(1, Math.floor(timeoutMs)); + const deadlineMs = started + timeoutBudgetMs; + const options = resolveProbeOptions(proxyOrOptions); + const fetcher = resolveProbeFetcher(token, options); const base = `${TELEGRAM_API_BASE}/bot${token}`; - const retryDelayMs = Math.max(50, Math.min(1000, timeoutMs)); + const retryDelayMs = Math.max(50, Math.min(1000, Math.floor(timeoutBudgetMs / 5))); + const resolveRemainingBudgetMs = () => Math.max(0, deadlineMs - Date.now()); const result: TelegramProbe = { ok: false, @@ -40,19 +117,35 @@ export async function probeTelegram( // Retry loop for initial connection (handles network/DNS startup races) for (let i = 0; i < 3; i++) { + const remainingBudgetMs = resolveRemainingBudgetMs(); + if (remainingBudgetMs <= 0) { + break; + } try { - meRes = await fetchWithTimeout(`${base}/getMe`, {}, timeoutMs, fetcher); + meRes = await fetchWithTimeout( + `${base}/getMe`, + {}, + Math.max(1, Math.min(timeoutBudgetMs, remainingBudgetMs)), + fetcher, + ); break; } catch (err) { fetchError = err; if (i < 2) { - await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + const remainingAfterAttemptMs = resolveRemainingBudgetMs(); + if (remainingAfterAttemptMs <= 0) { + break; + } + const delayMs = Math.min(retryDelayMs, remainingAfterAttemptMs); + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } } } } if (!meRes) { - throw fetchError; + throw fetchError ?? new Error(`probe timed out after ${timeoutBudgetMs}ms`); } const meJson = (await meRes.json()) as { @@ -89,16 +182,24 @@ export async function probeTelegram( // Try to fetch webhook info, but don't fail health if it errors. try { - const webhookRes = await fetchWithTimeout(`${base}/getWebhookInfo`, {}, timeoutMs, fetcher); - const webhookJson = (await webhookRes.json()) as { - ok?: boolean; - result?: { url?: string; has_custom_certificate?: boolean }; - }; - if (webhookRes.ok && webhookJson?.ok) { - result.webhook = { - url: webhookJson.result?.url ?? null, - hasCustomCert: webhookJson.result?.has_custom_certificate ?? null, + const webhookRemainingBudgetMs = resolveRemainingBudgetMs(); + if (webhookRemainingBudgetMs > 0) { + const webhookRes = await fetchWithTimeout( + `${base}/getWebhookInfo`, + {}, + Math.max(1, Math.min(timeoutBudgetMs, webhookRemainingBudgetMs)), + fetcher, + ); + const webhookJson = (await webhookRes.json()) as { + ok?: boolean; + result?: { url?: string; has_custom_certificate?: boolean }; }; + if (webhookRes.ok && webhookJson?.ok) { + result.webhook = { + url: webhookJson.result?.url ?? null, + hasCustomCert: webhookJson.result?.has_custom_certificate ?? null, + }; + } } } catch { // ignore webhook errors for probe diff --git a/src/telegram/proxy.test.ts b/src/telegram/proxy.test.ts index 27065d5c50c..4f2ca8f62e6 100644 --- a/src/telegram/proxy.test.ts +++ b/src/telegram/proxy.test.ts @@ -29,7 +29,7 @@ vi.mock("undici", () => ({ setGlobalDispatcher: mocks.setGlobalDispatcher, })); -import { makeProxyFetch } from "./proxy.js"; +import { getProxyUrlFromFetch, makeProxyFetch } from "./proxy.js"; describe("makeProxyFetch", () => { it("uses undici fetch with ProxyAgent dispatcher", async () => { @@ -46,4 +46,11 @@ describe("makeProxyFetch", () => { ); expect(mocks.setGlobalDispatcher).not.toHaveBeenCalled(); }); + + it("attaches proxy metadata for resolver transport handling", () => { + const proxyUrl = "http://proxy.test:8080"; + const proxyFetch = makeProxyFetch(proxyUrl); + + expect(getProxyUrlFromFetch(proxyFetch)).toBe(proxyUrl); + }); }); diff --git a/src/telegram/proxy.ts b/src/telegram/proxy.ts index c4cb7129a17..3ac2bb10159 100644 --- a/src/telegram/proxy.ts +++ b/src/telegram/proxy.ts @@ -1 +1 @@ -export { makeProxyFetch } from "../infra/net/proxy-fetch.js"; +export { getProxyUrlFromFetch, makeProxyFetch } from "../infra/net/proxy-fetch.js"; diff --git a/src/telegram/send.proxy.test.ts b/src/telegram/send.proxy.test.ts index ee47ec765c4..8e16078a67c 100644 --- a/src/telegram/send.proxy.test.ts +++ b/src/telegram/send.proxy.test.ts @@ -51,7 +51,12 @@ vi.mock("grammy", () => ({ InputFile: class {}, })); -import { deleteMessageTelegram, reactMessageTelegram, sendMessageTelegram } from "./send.js"; +import { + deleteMessageTelegram, + reactMessageTelegram, + resetTelegramClientOptionsCacheForTests, + sendMessageTelegram, +} from "./send.js"; describe("telegram proxy client", () => { const proxyUrl = "http://proxy.test:8080"; @@ -76,6 +81,8 @@ describe("telegram proxy client", () => { }; beforeEach(() => { + resetTelegramClientOptionsCacheForTests(); + vi.unstubAllEnvs(); botApi.sendMessage.mockResolvedValue({ message_id: 1, chat: { id: "123" } }); botApi.setMessageReaction.mockResolvedValue(undefined); botApi.deleteMessage.mockResolvedValue(true); @@ -87,6 +94,33 @@ describe("telegram proxy client", () => { resolveTelegramFetch.mockClear(); }); + it("reuses cached Telegram client options for repeated sends with same account transport settings", async () => { + const { fetchImpl } = prepareProxyFetch(); + vi.stubEnv("VITEST", ""); + vi.stubEnv("NODE_ENV", "production"); + + await sendMessageTelegram("123", "first", { token: "tok", accountId: "foo" }); + await sendMessageTelegram("123", "second", { token: "tok", accountId: "foo" }); + + expect(makeProxyFetch).toHaveBeenCalledTimes(1); + expect(resolveTelegramFetch).toHaveBeenCalledTimes(1); + expect(botCtorSpy).toHaveBeenCalledTimes(2); + expect(botCtorSpy).toHaveBeenNthCalledWith( + 1, + "tok", + expect.objectContaining({ + client: expect.objectContaining({ fetch: fetchImpl }), + }), + ); + expect(botCtorSpy).toHaveBeenNthCalledWith( + 2, + "tok", + expect.objectContaining({ + client: expect.objectContaining({ fetch: fetchImpl }), + }), + ); + }); + it.each([ { name: "sendMessage", diff --git a/src/telegram/send.ts b/src/telegram/send.ts index e1b352a0a61..313abf361e8 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -115,6 +115,12 @@ const MESSAGE_NOT_MODIFIED_RE = const CHAT_NOT_FOUND_RE = /400: Bad Request: chat not found/i; const sendLogger = createSubsystemLogger("telegram/send"); const diagLogger = createSubsystemLogger("telegram/diagnostic"); +const telegramClientOptionsCache = new Map(); +const MAX_TELEGRAM_CLIENT_OPTIONS_CACHE_SIZE = 64; + +export function resetTelegramClientOptionsCacheForTests(): void { + telegramClientOptionsCache.clear(); +} function createTelegramHttpLogger(cfg: ReturnType) { const enabled = isDiagnosticFlagEnabled("telegram.http", cfg); @@ -130,25 +136,74 @@ function createTelegramHttpLogger(cfg: ReturnType) { }; } +function shouldUseTelegramClientOptionsCache(): boolean { + return !process.env.VITEST && process.env.NODE_ENV !== "test"; +} + +function buildTelegramClientOptionsCacheKey(params: { + account: ResolvedTelegramAccount; + timeoutSeconds?: number; +}): string { + const proxyKey = params.account.config.proxy?.trim() ?? ""; + const autoSelectFamily = params.account.config.network?.autoSelectFamily; + const autoSelectFamilyKey = + typeof autoSelectFamily === "boolean" ? String(autoSelectFamily) : "default"; + const dnsResultOrderKey = params.account.config.network?.dnsResultOrder ?? "default"; + const timeoutSecondsKey = + typeof params.timeoutSeconds === "number" ? String(params.timeoutSeconds) : "default"; + return `${params.account.accountId}::${proxyKey}::${autoSelectFamilyKey}::${dnsResultOrderKey}::${timeoutSecondsKey}`; +} + +function setCachedTelegramClientOptions( + cacheKey: string, + clientOptions: ApiClientOptions | undefined, +): ApiClientOptions | undefined { + telegramClientOptionsCache.set(cacheKey, clientOptions); + if (telegramClientOptionsCache.size > MAX_TELEGRAM_CLIENT_OPTIONS_CACHE_SIZE) { + const oldestKey = telegramClientOptionsCache.keys().next().value; + if (oldestKey !== undefined) { + telegramClientOptionsCache.delete(oldestKey); + } + } + return clientOptions; +} + function resolveTelegramClientOptions( account: ResolvedTelegramAccount, ): ApiClientOptions | undefined { - const proxyUrl = account.config.proxy?.trim(); - const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined; - const fetchImpl = resolveTelegramFetch(proxyFetch, { - network: account.config.network, - }); const timeoutSeconds = typeof account.config.timeoutSeconds === "number" && Number.isFinite(account.config.timeoutSeconds) ? Math.max(1, Math.floor(account.config.timeoutSeconds)) : undefined; - return fetchImpl || timeoutSeconds - ? { - ...(fetchImpl ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } : {}), - ...(timeoutSeconds ? { timeoutSeconds } : {}), - } - : undefined; + + const cacheEnabled = shouldUseTelegramClientOptionsCache(); + const cacheKey = cacheEnabled + ? buildTelegramClientOptionsCacheKey({ + account, + timeoutSeconds, + }) + : null; + if (cacheKey && telegramClientOptionsCache.has(cacheKey)) { + return telegramClientOptionsCache.get(cacheKey); + } + + const proxyUrl = account.config.proxy?.trim(); + const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined; + const fetchImpl = resolveTelegramFetch(proxyFetch, { + network: account.config.network, + }); + const clientOptions = + fetchImpl || timeoutSeconds + ? { + ...(fetchImpl ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } : {}), + ...(timeoutSeconds ? { timeoutSeconds } : {}), + } + : undefined; + if (cacheKey) { + return setCachedTelegramClientOptions(cacheKey, clientOptions); + } + return clientOptions; } function resolveToken(explicit: string | undefined, params: { accountId: string; token: string }) {