Skip to content

Telegram: fix exec approvals and topic follow-up routing#37233

Merged
huntharo merged 12 commits intoopenclaw:mainfrom
huntharo:telegram-exec-approvals
Mar 10, 2026
Merged

Telegram: fix exec approvals and topic follow-up routing#37233
huntharo merged 12 commits intoopenclaw:mainfrom
huntharo:telegram-exec-approvals

Conversation

@huntharo
Copy link
Copy Markdown
Member

@huntharo huntharo commented Mar 6, 2026

Summary

Describe the problem and fix in 2–5 bullets:

  • Problem: Telegram exec approvals could fail with unknown approval id due to approval-id mismatch/rotation between prompt and resolve path, approved runs could still not post the follow-up result promptly, and heartbeat-delivered follow-ups could land in group #general instead of the active topic.
  • Why it matters: Elevated commands block waiting for approval; approval failures or delayed post-approval replies make Telegram unreliable for gated exec flows.
  • What changed: Normalize /approve handling, support short unique id prefixes, inject canonical Telegram approval buttons from the exact pending approval id across Telegram delivery paths, allow event-driven heartbeat wakes (exec-event) to run even when periodic heartbeat config is off, and preserve explicit session topic/thread IDs for exec-event heartbeat delivery routing.
  • What did NOT change (scope boundary): No ACP feature changes intended; fix is scoped to exec approval id resolution + Telegram approval UX/routing + post-approval event wake behavior.

Specific Issues Fixed

  • execApprovals on in Discord - Send /coding_agent exec approval from Telegram
    • Discord DM appears with approval buttons
    • Issue: clicking approval button says the request failed
  • Telegram could not approve exec approvals at all
    • Telegram did not have the execApprovals config gate or approvers list
    • Issue: Telegram would always fail to approve a request even if you figured out how to try it
  • Telegram had no config to enable and restrict approvers list
    • Issue: wouldn't have been safe even if it did work as it may have allowed any approver
  • Telegram did not have buttons on the LLM-generated approval texts that it sent
    • Issue: these were not really real... they were no going to work and should not have been sent
    • Better: (didn't do this yet) - it would be best to say "approval requested in enabled channels, check Web UI or other channels"

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

  • Closes #
  • Related #

User-visible / Behavior Changes

  • Telegram approval prompts now include canonical /approve <id> allow-once|allow-always|deny actions and inline buttons.
  • /approve accepts Telegram bot mentions (for example /approve@bot ...).
  • Gateway resolves unique short id prefixes and returns deterministic errors for unknown/expired or ambiguous ids.
  • Post-approval follow-up responses for exec-event wakes no longer depend on periodic heartbeat interval config being enabled.
  • Topic-scoped Telegram runs keep their post-approval follow-up in the same topic (no fallback to group #general).

Security Impact (required)

  • New permissions/capabilities? (No)
  • Secrets/tokens handling changed? (No)
  • New/changed network calls? (No)
  • Command/tool execution surface changed? (No)
  • Data access scope changed? (No)
  • If any Yes, explain risk + mitigation:

Repro + Verification

Environment

  • OS: macOS
  • Runtime/container: local OpenClaw gateway + Telegram integration
  • Model/provider: n/a (approval routing path)
  • Integration/channel (if any): Telegram
  • Relevant config (redacted): Telegram chat + exec approvals enabled

Steps

  1. In Telegram, ask the agent to run an elevated command that requires approval.
  2. Receive approval prompt/id from bot.
  3. Submit approval via chat command (/approve <id> allow-once) or inline callback.

Example Prompt

/coding_agent Let's run npm to find out if there is a package called diver.  You'll need to ask for approval I believe.

Expected

  • Approval id resolves successfully and pending command continues.

Actual

  • On main (broken), approval submit fails with unknown approval id and command remains pending.

Evidence

Attach at least one:

  • Failing test/log before + passing after
  • Trace/log snippets
  • Screenshot/recording
  • Perf numbers (if relevant)

Main branch logs (broken behavior)

17:12:27 [agents/auth-profiles] adopted newer OAuth credentials from main agent
17:12:36 [exec] elevated command npm view diver name version description
17:12:39 [telegram] sendMessage ok chat=-1003841603622 message=922
17:13:31 [agents/auth-profiles] adopted newer OAuth credentials from main agent
17:13:34 [exec] elevated command npm view diver name version description
17:13:49 [ws] ⇄ res ✗ exec.approval.resolve 0ms errorCode=INVALID_REQUEST errorMessage=unknown approval id conn=ca8fc2ce…3dd1 id=078dec1a…9843
17:13:50 [telegram] sendMessage ok chat=-1003841603622 message=926
17:14:36 [ws] ⇄ res ✓ exec.approval.waitDecision 119995ms conn=c09df06e…af11 id=269177e3…7ee0
17:15:34 [ws] ⇄ res ✓ exec.approval.waitDecision 119994ms conn=bcb9e6a0…a007 id=70bde358…b28b

Branch logs (intermediate behavior after approval-id fix)

17:20:23 [telegram] [default] starting provider (@example_bot)
17:20:23 [telegram] autoSelectFamily=true (default-node22)
17:20:23 [telegram] dnsResultOrder=ipv4first (default-node22)
17:20:23 [telegram] webhook local listener on http://127.0.0.1:8787/telegram-webhook
17:20:23 [telegram] webhook advertised to telegram on https://oc-tg.example.com/telegram-webhook
17:21:25 [agents/auth-profiles] adopted newer OAuth credentials from main agent
17:21:34 [exec] elevated command npm view diver name version description
17:21:38 [telegram] sendMessage ok chat=-1003841603622 message=930
17:21:47 [ws] ⇄ res ✓ exec.approval.waitDecision 13248ms conn=a0229d33…9a77 id=b74bee7f…696d

Branch logs (remaining delayed follow-up symptom)

17:34:11 [plugins] acpx runtime backend ready
17:34:11 [telegram] [default] starting provider (@example_bot)
17:34:11 [telegram] autoSelectFamily=true (default-node22)
17:34:11 [telegram] dnsResultOrder=ipv4first (default-node22)
17:34:11 [telegram] webhook local listener on http://127.0.0.1:8787/telegram-webhook
17:34:11 [telegram] webhook advertised to telegram on https://oc-tg.example.com/telegram-webhook
17:44:31 [agents/auth-profiles] adopted newer OAuth credentials from main agent
17:44:36 [exec] elevated command npm view diver name version description
17:44:39 [telegram] sendMessage ok chat=-1003841603622 message=933
17:44:47 [ws] ⇄ res ✓ exec.approval.waitDecision 11032ms conn=0a9fc576…bbe8 id=0b6fa355…e202

Before - Telegram - Cannot Approve Exec on Telegram - At All

  • Asks for approval - free form response doesn't work
  • Approval ID changes - reply with /approve [id] allow-once - says it doesn't know that ID
  • This repeats ad nauseum
image

After - Telegram - Command Presented / BUTTONS!!!

image

After - Telegram - Command Logged / Buttons Cleared After Click / Approval Works

image

After - Discord - execApprovals disabled or enabled but DM-only

image

Human Verification (required)

What you personally verified (not just CI), and how:

  • Verified scenarios: Telegram approval prompt generation, /approve command parsing, approval id prefix resolution, Telegram inline callback flow.
  • Edge cases checked: Mentioned command form (/approve@bot), unknown/expired ids, ambiguous prefix handling, callback-data length limits.
  • What you did not verify: Non-Telegram channels beyond automated tests.

Compatibility / Migration

  • Backward compatible? (Yes)
  • Config/env changes? (No)
  • Migration needed? (No)
  • If yes, exact upgrade steps:

Failure Recovery (if this breaks)

  • How to disable/revert this change quickly: revert these commits from branch telegram-exec-approvals.
  • Files/config to restore: exec approval resolver + Telegram approval-button injection path + heartbeat event-driven wake handling.
  • Known bad symptoms reviewers should watch for: unknown approval id on valid approvals; delayed/no post-approval follow-up response until a later user message wakes execution.

Risks and Mitigations

List only real risks for this PR. Add/remove entries as needed. If none, write None.

  • Risk: Event-driven wake fallback could run in environments that intentionally disable periodic heartbeat.
    • Mitigation: fallback is limited to explicit event-driven reasons and preserves existing disabled behavior for periodic runs.
  • Risk: Prefix resolution could resolve unintended id if matching rules are too loose.
    • Mitigation: only unique prefixes resolve; ambiguous prefixes return explicit error requiring full id.
  • Risk: Telegram button injection could override intentional custom buttons.
    • Mitigation: injection is skipped when existing Telegram buttons are present.

@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot Bot commented Mar 6, 2026

🔒 Aisle Security Analysis

We found 4 potential security issue(s) in this PR:

# Severity Title
1 🟠 High Brute-forceable exec approval resolution via unrestricted prefix matching (and ID disclosure on ambiguity)
2 🟡 Medium Audit/notification evasion via user-controllable suppressNotifyOnExit in node system.run
3 🟡 Medium Telegram exec approval prompts can be misdelivered to attacker-chosen chat via untrusted turnSourceTo
4 🔵 Low Telegram exec approval prompts can be silently dropped due to unconditional local suppression

1. 🟠 Brute-forceable exec approval resolution via unrestricted prefix matching (and ID disclosure on ambiguity)

Property Value
Severity High
CWE CWE-639
Location src/gateway/server-methods/exec-approval.ts:282-307

Description

The exec.approval.resolve endpoint now accepts case-insensitive ID prefixes of any length via ExecApprovalManager.lookupPendingId(). This substantially reduces the effective entropy of approval IDs and enables resolving approvals without knowing the full ID.

Impact:

  • Brute-force / wrong-target approval: If only a small number of approvals are pending (common), many 1–2 character prefixes will uniquely identify an approval. An attacker with access to the operator.approvals surface can iterate prefixes until one uniquely matches and then allow/deny an approval they never received.
  • Information disclosure: When a prefix is ambiguous, the error message returns up to 3 full matching approval IDs (matches: ...), which further accelerates guessing and leaks identifiers.

Why this is exploitable:

  • Input id is only validated as NonEmptyString (no UUID format, no max length, no minimum prefix length).
  • lookupPendingId() performs prefix matching with no minimum prefix size.
  • exec.approval.resolve is not covered by any rate limiter like consumeControlPlaneWriteBudget() (only applied to config/update methods), so repeated guesses are not throttled at the gateway layer.

Concrete scenario:

  • A backend integration (or chat approval client) has operator.approvals scope but is not supposed to approve requests it never saw (e.g., filtered delivery).
  • With one pending approval whose UUID starts with a..., sending exec.approval.resolve { id: "a", decision: "deny" } will resolve it (unique prefix).
  • If multiple approvals start with the same prefix, the attacker can intentionally trigger the ambiguous error to obtain candidate IDs, then resolve exactly.

Recommendation

Mitigate prefix-guessing and misbinding by tightening resolution semantics:

  1. Enforce a minimum prefix length (e.g., 8+ chars) and/or require UUID formatting.
  2. Do not echo full candidate IDs in ambiguous-prefix errors (return only a count, or masked IDs).
  3. Add rate limiting for exec.approval.resolve attempts (per device/IP), especially for failed/unknown/ambiguous lookups.
  4. Add a maxLength constraint for params.id to avoid oversized allocations.

Example (minimum prefix length + avoid leaking IDs):

// exec.approval.resolve
const raw = p.id.trim();
if (raw.length < 8) {
  respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "approval id prefix too short"));
  return;
}
const resolved = manager.lookupPendingId(raw);
if (resolved.kind === "ambiguous") {
  respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "ambiguous approval id prefix; use full id"));
  return;
}

And in the schema:

export const ExecApprovalResolveParamsSchema = Type.Object({
  id: Type.String({ minLength: 8, maxLength: 128 }),
  decision: NonEmptyString,
}, { additionalProperties: false });

2. 🟡 Audit/notification evasion via user-controllable suppressNotifyOnExit in node system.run

Property Value
Severity Medium
CWE CWE-778
Location src/gateway/server-node-events.ts:534-543

Description

The new suppressNotifyOnExit flag allows a node.invoke caller to suppress gateway-side exec.* system event notifications, reducing operator awareness and enabling audit/notification evasion.

Data flow (all in this diff):

  • Input: suppressNotifyOnExit can be provided by the node.invoke caller inside params for command: "system.run".
    • The gateway explicitly allowlists and forwards it in sanitizeSystemRunParamsForForwarding() via pickSystemRunParams().
  • Propagation: node-host reads opts.params.suppressNotifyOnExit === true and includes it in the emitted exec.denied / exec.finished node events.
  • Sink: gateway handleNodeEvent() drops all exec notifications (exec.started, exec.denied, exec.finished) when obj.suppressNotifyOnExit === true, preventing enqueueSystemEvent() and heartbeat wake.

Security impact:

  • A compromised tool/plugin/agent (or any actor with access to node.invoke / system.run) can set this flag to hide command lifecycle notifications from the session’s system event stream.
  • Because system events are ephemeral (in-memory) and not persisted elsewhere, this can materially reduce traceability/awareness in deployments that rely on these events for operational auditing.
  • The suppression is not bound to exec-approval flows or privileged scopes; it is honored purely based on the payload flag.

Vulnerable code:

// src/gateway/server-node-events.ts
const notifyOnExit = cfg.tools?.exec?.notifyOnExit !== false;
if (!notifyOnExit) {
  return;
}
if (obj.suppressNotifyOnExit === true) {
  return;
}

Recommendation

Do not allow untrusted callers to suppress exec notifications.

Options (pick one depending on intended feature semantics):

  1. Strip/ignore suppressNotifyOnExit unless the request is trusted (recommended):

    • Remove suppressNotifyOnExit from the forwarded allowlist in pickSystemRunParams().
    • Or only forward it when the gateway can verify the request is part of a trusted internal exec-approval flow (e.g., internal client identity / elevated scope such as operator.admin).
  2. Always log/audit regardless, only suppress user-facing delivery:

    • Keep enqueueSystemEvent() (or write to a persistent audit log) even if you suppress outbound/user notifications.

Example hardening (strip for normal callers):

// src/gateway/node-invoke-system-run-approval.ts
function pickSystemRunParams(raw: Record<string, unknown>): Record<string, unknown> {
  const next: Record<string, unknown> = {};
  for (const key of [
    "command","rawCommand","systemRunPlan","cwd","env","timeoutMs",
    "needsScreenRecording","agentId","sessionKey","runId",// "suppressNotifyOnExit"  // do not forward from user input
  ]) {
    if (key in raw) next[key] = raw[key];
  }
  return next;
}// If needed, inject suppressNotifyOnExit only for vetted internal paths.

3. 🟡 Telegram exec approval prompts can be misdelivered to attacker-chosen chat via untrusted turnSourceTo

Property Value
Severity Medium
CWE CWE-200
Location src/telegram/exec-approvals-handler.ts:165-182

Description

The new TelegramExecApprovalHandler trusts routing metadata inside ExecApprovalRequest to decide where to send sensitive approval prompts (command text, cwd, nodeId).

If request.request.turnSourceChannel === "telegram", the handler uses turnSourceTo and turnSourceThreadId directly as the Telegram destination (only checking turnSourceAccountId when it is present). There is no validation that these fields actually correspond to the originating session/chat, nor that they match a session-store-derived delivery target.

Impact:

  • A compromised or malicious component that can emit/call exec.approval.request (i.e., any gateway client with operator.approvals) can set turnSourceTo to an arbitrary Telegram chat ID (DM or group) that the bot can message.
  • The handler will then send the approval prompt containing the command and metadata to that arbitrary chat, causing information disclosure/misdelivery.

Vulnerable code (target selection trusts request fields):

if (turnSourceChannel === "telegram" && turnSourceTo) {
  if (turnSourceAccountId && normalizeAccountId(turnSourceAccountId) !== normalizeAccountId(params.accountId)) {
    return null;
  }
  const threadId = /* parse */;
  return { to: turnSourceTo, threadId: Number.isFinite(threadId) ? threadId : undefined };
}

Sensitive content is then delivered to target.to:

await this.sendMessage(target.to, payload.text ?? "", { ... });

Notes:

  • If turnSourceAccountId is omitted/empty, the account check is skipped, which can exacerbate misdelivery in multi-account deployments.

Recommendation

Do not treat turnSource* fields in ExecApprovalRequest as authoritative routing.

Hardening options (preferably combine 1 + 2):

  1. Bind to session-store-derived target: when turnSourceChannel === "telegram", require that sessionKey exists and resolves (via session store) to the same to/accountId/threadId. If it doesn’t match, ignore channel routing (or fall back to DM approvers).
const direct = /* parse turnSourceTo/threadId */;
const sessionTarget = resolveRequestSessionTarget(params);
if (
  !sessionTarget ||
  sessionTarget.channel !== "telegram" ||
  sessionTarget.to !== direct.to ||
  (sessionTarget.threadId ?? undefined) !== (direct.threadId ?? undefined)
) {
  return null; // or fallbackToDm
}
  1. Require account binding: if supporting multiple Telegram accounts, require a non-empty turnSourceAccountId and match it strictly.

  2. Constrain acceptable destinations: optionally reject non-numeric chat IDs and/or destinations not present in an allowlist derived from session data.

Additionally, consider enforcing this upstream in the gateway: reject/strip turnSource* fields from exec.approval.request unless they are computed by trusted server-side context.


4. 🔵 Telegram exec approval prompts can be silently dropped due to unconditional local suppression

Property Value
Severity Low
CWE CWE-703
Location src/telegram/exec-approvals.ts:98-106

Description

The new Telegram suppression logic drops any ReplyPayload that contains channelData.execApproval metadata, without verifying that a Telegram exec-approval handler is actually configured/running (or that it will route this approval).

This causes a loss of the user-visible approval prompt (and can make the bot appear to “hang”) in situations such as:

  • Telegram bot is running reply dispatch (createTelegramBot), but the separate monitor-side TelegramExecApprovalHandler is not running/connected (e.g., alternative runtime topology, transient gateway disconnect, or handler startup failure).
  • TelegramExecApprovalHandler is enabled but its agentFilter / sessionFilter does not match a request (so it will not send prompts), while local dispatch still suppresses the only prompt payload.

Because the suppression paths also mark the reply as delivered (queuedFinal=true / deliveryState.delivered=true), Telegram fallback messages are skipped, increasing the likelihood of silent DoS (no approval instructions shown anywhere in the chat).

Vulnerable logic:

export function shouldSuppressLocalTelegramExecApprovalPrompt(...) {
  void params.cfg;
  void params.accountId;
  return getExecApprovalReplyMetadata(params.payload) !== null;
}

and its usage in Telegram dispatch paths:

  • dispatchTelegramMessage returns early and sets queuedFinal = true
  • native command dispatcher returns early and sets deliveryState.delivered = true

Recommendation

Only suppress the local approval prompt when you can ensure an alternative delivery path exists for this approval request.

Minimum hardening (match Discord behavior): gate suppression on Telegram exec-approvals being enabled for the account.

import { isTelegramExecApprovalClientEnabled } from "./exec-approvals.js";

export function shouldSuppressLocalTelegramExecApprovalPrompt(params: {
  cfg: OpenClawConfig;
  accountId?: string | null;
  payload: ReplyPayload;
}): boolean {
  return (
    isTelegramExecApprovalClientEnabled({ cfg: params.cfg, accountId: params.accountId }) &&
    getExecApprovalReplyMetadata(params.payload) !== null
  );
}

Stronger fix (prevents silent hangs):

  • If suppression triggers, send a minimal notice to the originating chat (without channelData.execApproval) such as “Approval required; check approver DMs / web UI”, unless you are sending to the same chat (target: channel|both).
  • Alternatively, avoid setting queuedFinal/delivered when suppressing unless you have confirmed that the monitor-side handler actually delivered a prompt.

This preserves availability and prevents missing security-relevant prompts due to handler outages or filter misconfiguration.


Analyzed PR: #37233 at commit f243379

Last updated on: 2026-03-10T05:10:33Z

@openclaw-barnacle openclaw-barnacle Bot added channel: telegram Channel integration: telegram gateway Gateway runtime agents Agent runtime and tooling size: XL trusted-contributor labels Mar 6, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 6, 2026

Greptile Summary

This PR fixes a multi-part regression in Telegram exec-approval flows: approval-id mismatch/rotation, missing approver gating on Telegram's /approve command, missing inline approval buttons, and post-approval follow-up messages landing in the wrong topic. The fix is well-scoped and comes with extensive new tests covering approval ID prefix resolution, button injection, forwarder Telegram delivery, and the suppressed-notify-on-exit path.

Key changes:

  • ExecApprovalManager.lookupPendingId adds prefix-match + ambiguity resolution, consumed by the gateway resolve handler.
  • New src/telegram/exec-approvals.ts module centralises Telegram approval config/approver checks used consistently across the forwarder, delivery layer, and /approve command handler.
  • buildExecApprovalPendingReplyPayload emits structured channelData.execApproval metadata, enabling deterministic button injection without relying on text-regex extraction.
  • sendExecApprovalFollowup replaces heartbeat/system-event delivery for post-approval results, preserving the originating topic/thread ID for Telegram forum topics.
  • suppressNotifyOnExit flag prevents the legacy node-exit heartbeat path from firing a duplicate notification alongside the new direct follow-up.

Two issues found:

  1. Misleading follow-up prompt preamble (src/agents/bash-tools.exec-approval-followup.ts): buildExecApprovalFollowupPrompt always opens with "An async command the user already approved has completed," even when called with denial result text such as "Exec denied (..., approval-request-failed): ..." where no approval ever occurred.
  2. Duplicate forwarding for shared targets (src/infra/exec-approval-forwarder.ts): handleRequested and handleResolved concatenate resolveForwardTargets and resolveTelegramForwardTargets without cross-set deduplication. An approver present in both approvals.exec.targets and channels.telegram.execApprovals.approvers would receive two copies of every approval/resolution message.

Confidence Score: 3/5

  • Safe to merge for core approval-ID and Telegram routing fixes; two logic issues should be addressed before relying on multi-channel forwarder configs in production.
  • The approval-ID resolution, Telegram approver gating, and follow-up routing fixes address the stated regressions and are well-tested. Two verified issues remain: (1) misleading preamble in post-approval follow-up prompts for denial flows could nudge LLM toward incorrect framing, (2) missing cross-deduplication in forwarder targets can cause duplicate notifications if the same approver is configured in both general and Telegram-specific settings. Neither blocks the primary happy-path scenarios, but both should be fixed before multi-channel forwarder deployments.
  • src/agents/bash-tools.exec-approval-followup.ts (preamble accuracy for denial flows) and src/infra/exec-approval-forwarder.ts (cross-deduplication of combined forward targets)

Last reviewed commit: 12025dd

lines.push("Mode: foreground (interactive approvals available in this chat).");
lines.push(
"Background mode note: non-interactive runs cannot wait for chat approvals; use pre-approved policy (allow-always or ask=off).",
);
lines.push("Reply with: /approve <id> allow-once|allow-always|deny");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forwarder approval messages will not receive inline buttons.

buildRequestMessage uses a literal <id> placeholder instead of the actual approval ID. The extractApprovalIdFromText function relies on a regex pattern [A-Za-z0-9][A-Za-z0-9._:-]* to extract the approval ID from the "Reply with:" line. The literal <id> won't match this pattern, so injectTelegramApprovalButtons will skip button injection for forwarder-delivered messages.

This means approval notifications routed through the forwarder won't have interactive inline buttons on Telegram, even though agent-generated messages (via bash-tools) will.

To fix, inject the actual request.id:

Suggested change
lines.push("Reply with: /approve <id> allow-once|allow-always|deny");
lines.push(`Reply with: /approve ${request.id} allow-once|allow-always|deny`);

Context Used: Rule from dashboard - AGENTS.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/infra/exec-approval-forwarder.ts
Line: 198

Comment:
Forwarder approval messages will not receive inline buttons. 

`buildRequestMessage` uses a literal `<id>` placeholder instead of the actual approval ID. The `extractApprovalIdFromText` function relies on a regex pattern `[A-Za-z0-9][A-Za-z0-9._:-]*` to extract the approval ID from the "Reply with:" line. The literal `<id>` won't match this pattern, so `injectTelegramApprovalButtons` will skip button injection for forwarder-delivered messages.

This means approval notifications routed through the forwarder won't have interactive inline buttons on Telegram, even though agent-generated messages (via bash-tools) will.

To fix, inject the actual `request.id`:

```suggestion
  lines.push(`Reply with: /approve ${request.id} allow-once|allow-always|deny`);
```

**Context Used:** Rule from `dashboard` - AGENTS.md ([source](https://app.greptile.com/review/custom-context?memory=a1d58d20-b4dd-4cbb-973a-9fd7824e1921))

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3c3caaa1b8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/infra/heartbeat-runner.ts Outdated
Comment on lines 630 to 637
if (!heartbeatsEnabled && !isEventDrivenReason) {
return { status: "skipped", reason: "disabled" };
}
if (!isHeartbeatEnabledForAgent(cfg, agentId)) {
if (!isHeartbeatEnabledForAgent(cfg, agentId) && !isEventDrivenReason) {
return { status: "skipped", reason: "disabled" };
}
if (!resolveHeartbeatIntervalMs(cfg, undefined, heartbeat)) {
if (!resolveHeartbeatIntervalMs(cfg, undefined, heartbeat) && !isEventDrivenReason) {
return { status: "skipped", reason: "disabled" };
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Honor heartbeat disable switches for event-driven wakes

runHeartbeatOnce now bypasses the global and per-agent disable guards whenever the reason is event-driven, so exec-event/cron/hook/wake requests will still run even after heartbeats were explicitly disabled (globally via set-heartbeats false or for an agent without heartbeat enabled). In multi-agent setups this re-enables follow-up turns for agents that were intentionally disabled, which is a behavior regression from the prior disabled skip path.

Useful? React with 👍 / 👎.

Comment thread src/telegram/approval-buttons.ts Outdated
Comment on lines +53 to +55
const text = payload.text?.trim();
if (!text || !text.includes("/approve")) {
return payload;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict approval button injection to approval prompts

This helper injects actionable Telegram /approve buttons for any text payload that happens to contain /approve, without verifying the message is an approval-pending response. As a result, ordinary replies (help text, copied logs, or examples) can get one-tap approval actions, and if the extracted ID matches a real pending request the click can resolve it unintentionally.

Useful? React with 👍 / 👎.

@huntharo huntharo force-pushed the telegram-exec-approvals branch 2 times, most recently from 8dc2fc3 to a9efbee Compare March 6, 2026 13:11
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a9efbee85d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/telegram/bot-message-dispatch.ts Outdated
Comment on lines +169 to +170
const autoApprovalButtonsEnabled =
resolveTelegramInlineButtonsScope({ cfg, accountId: route.accountId }) !== "off";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor dm/group scope before auto-injecting approval buttons

autoApprovalButtonsEnabled currently treats every scope except off as eligible, so accounts configured with capabilities.inlineButtons: "dm" will still get /approve buttons in group chats (and group scope will still inject in DMs). Those callbacks are then rejected by the existing scope gate in src/telegram/bot-handlers.ts:1126-1130, leaving users with visible but non-functional approval buttons. This same !== "off" check is also repeated in src/telegram/bot-native-commands.ts:564-565 and src/infra/outbound/deliver.ts:308-309, so injection should be gated by chat type/scope, not just off.

Useful? React with 👍 / 👎.

@huntharo
Copy link
Copy Markdown
Member Author

huntharo commented Mar 6, 2026

@greptile-apps please review latest code

Comment on lines +185 to +207
const exact = this.pending.get(normalized);
if (exact && exact.record.resolvedAtMs === undefined) {
return { kind: "exact", id: normalized };
}

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" };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When an exact entry is found but is already resolved (resolvedAtMs !== undefined), this function doesn't return early and falls through to the prefix scan. This could match a different, still-pending approval whose ID happens to start with the submitted (now-resolved) string.

For example:

  • Resolved: "abc123-111-xxxx" (full ID)
  • Pending: "abc123-111-yyyy" (full ID, shares prefix)
  • User submits: "abc123-111-xxxx"

The function finds the exact match but it's resolved, so it continues to the prefix scan and matches the wrong pending entry instead of returning "unknown or expired".

To fix, return immediately when an exact match is found but already resolved:

Suggested change
const exact = this.pending.get(normalized);
if (exact && exact.record.resolvedAtMs === undefined) {
return { kind: "exact", id: normalized };
}
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" };
const exact = this.pending.get(normalized);
if (exact) {
if (exact.record.resolvedAtMs === undefined) {
return { kind: "exact", id: normalized };
}
// Exact ID found but already resolved — do not fall through to prefix match.
return { 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" };

This ensures that a full ID that was already consumed returns the correct "unknown or expired" error instead of potentially matching a concurrent unresolved entry.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/exec-approval-manager.ts
Line: 185-207

Comment:
When an exact entry is found but is already resolved (`resolvedAtMs !== undefined`), this function doesn't return early and falls through to the prefix scan. This could match a different, still-pending approval whose ID happens to start with the submitted (now-resolved) string.

For example:
- Resolved: "abc123-111-xxxx" (full ID)  
- Pending: "abc123-111-yyyy" (full ID, shares prefix)
- User submits: "abc123-111-xxxx"

The function finds the exact match but it's resolved, so it continues to the prefix scan and matches the wrong pending entry instead of returning "unknown or expired".

To fix, return immediately when an exact match is found but already resolved:

```suggestion
    const exact = this.pending.get(normalized);
    if (exact) {
      if (exact.record.resolvedAtMs === undefined) {
        return { kind: "exact", id: normalized };
      }
      // Exact ID found but already resolved — do not fall through to prefix match.
      return { 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" };
```

This ensures that a full ID that was already consumed returns the correct "unknown or expired" error instead of potentially matching a concurrent unresolved entry.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 982bb955c0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/infra/outbound/deliver.ts Outdated
Comment on lines +312 to +314
let sanitizedPayload = canInjectTelegramButtons
? injectTelegramApprovalButtons(payload)
: payload;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve markdown escaping when injecting Telegram approval buttons

Injecting buttons here adds channelData to plain approval text payloads, which makes deliverOutboundPayloads take the Telegram sendPayload path (instead of chunked sendText) and sends raw text with textMode: "html" (src/channels/plugins/outbound/telegram.ts). Approval prompts include unescaped shell commands, so common inputs like &&, <, or & can trigger Telegram HTML parse errors and drop the approval message, leaving elevated runs pending with no actionable prompt.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 596a5f5df2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/infra/outbound/deliver.ts Outdated
@huntharo huntharo force-pushed the telegram-exec-approvals branch from 596a5f5 to 4eaece3 Compare March 8, 2026 01:56
@openclaw-barnacle openclaw-barnacle Bot added the cli CLI command changes label Mar 8, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4eaece320d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/infra/heartbeat-runner.ts Outdated
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9577900767

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/infra/heartbeat-runner.ts Outdated
Comment on lines +509 to +510
if (!explicitTo) {
return true;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict Telegram thread carry to Telegram-source sessions

When heartbeat.target is telegram and heartbeat.to is unset, this branch always returns true, so runHeartbeatOnce forwards parseSessionThreadInfo(sessionKey).threadId as explicitThreadId for every event-driven wake. For non-Telegram threaded sessions (for example agent:...:main:thread:42 or Discord thread keys), that foreign thread id is then attached to Telegram delivery and can misroute the follow-up into an unrelated topic or fail with Telegram thread-not-found errors. The carry decision should require a Telegram group source session before reusing the source thread id.

Useful? React with 👍 / 👎.

@huntharo huntharo self-assigned this Mar 9, 2026
@huntharo
Copy link
Copy Markdown
Member Author

huntharo commented Mar 9, 2026

❌ I'm refactoring to remove the heartbeat logic while preserving the ability for the notification of completion after the approval.

@huntharo huntharo force-pushed the telegram-exec-approvals branch from 9577900 to c5a884a Compare March 9, 2026 12:11
@openclaw-barnacle openclaw-barnacle Bot added maintainer Maintainer-authored PR and removed cli CLI command changes labels Mar 9, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c5a884ab6b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@@ -4,7 +4,7 @@ import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-
import { requireGatewayClientScopeForInternalChannel } from "./command-gates.js";
import type { CommandHandler } from "./commands-types.js";

const COMMAND = "/approve";
const COMMAND_WITH_MENTION_REGEX = /^\/approve(?:@[^\s]+)?(?:\s|$)/i;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Ignore /approve mentions targeting other Telegram bots

The new parser accepts any /approve@... prefix and then resolves approvals without checking whether the mention is for this bot. In a Telegram group with multiple bots, /approve@otherbot <id> allow-once will now be treated as a valid OpenClaw approval command, so an authorized operator can accidentally resolve OpenClaw’s pending approval while trying to control another bot. This is a regression from the old behavior (which rejected mention forms) and needs an explicit bot-username match before accepting mentioned /approve commands.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3766f858f4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +190 to +193
const unavailableReason =
preResolvedDecision === null
? "no-approval-route"
: initiatingSurface.kind === "disabled"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid marking auto-fallback approvals as unavailable

unavailableReason is set to "no-approval-route" as soon as preResolvedDecision === null, but the async branch still applies askFallback and can continue execution (for example with askFallback: "full"). In that case users receive an approval-unavailable message that tells them to retry even though the command may already be running, which can trigger duplicate side effects when they retry; the unavailable status should only be emitted when fallback logic will actually deny execution (this same pattern is mirrored in the node-host path).

Useful? React with 👍 / 👎.

Comment on lines +167 to +170
const requestChannel = normalizeMessageChannel(params.request.request.turnSourceChannel ?? "");
if (requestChannel !== "telegram") {
return false;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep explicit Telegram forward targets for Telegram runs

This skip gate suppresses Telegram forwarding whenever the source channel is Telegram, regardless of which Telegram destination was configured. Since it never checks whether the forward target matches the source chat/topic, explicit approvals.exec.forward.targets (for example a separate ops/audit Telegram chat) are filtered out for Telegram-originated approvals, so those routes stop receiving approval prompts after this change.

Useful? React with 👍 / 👎.

@huntharo huntharo force-pushed the telegram-exec-approvals branch from 3766f85 to f243379 Compare March 10, 2026 03:04
@huntharo huntharo merged commit de49a8b into openclaw:main Mar 10, 2026
25 of 26 checks passed
@huntharo huntharo deleted the telegram-exec-approvals branch March 10, 2026 03:04
@huntharo
Copy link
Copy Markdown
Member Author

Merged via squash.

Thanks @huntharo!

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f243379094

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +384 to +387
this.pending.set(request.id, {
timeoutId,
messages: sentMessages,
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Register pending Telegram approvals before sending prompts

handleRequested only inserts the request into this.pending after all sendMessage calls finish, so there is a race where an approval resolved from another surface (Web UI/Discord/CLI) can trigger handleResolved first, return early because no pending entry exists, and never clear the just-sent buttons. In that case Telegram users keep stale approval buttons that fail with unknown/expired ID errors until timeout.

Useful? React with 👍 / 👎.

@@ -147,6 +155,48 @@ function shouldSkipDiscordForwarding(
return Boolean(execApprovals?.enabled && (execApprovals.approvers?.length ?? 0) > 0);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor Discord filters before suppressing text forward targets

shouldSkipDiscordForwarding suppresses Discord forward targets as soon as account-level exec approvals are enabled with approvers, but it does not account for agentFilter/sessionFilter. Because DiscordExecApprovalsHandler.shouldHandle still rejects filtered requests, those requests can be dropped by both paths (no forwarded text prompt and no Discord component prompt), leaving approvals without a delivery route and forcing timeout.

Useful? React with 👍 / 👎.

aiwatching pushed a commit to aiwatching/openclaw that referenced this pull request Mar 10, 2026
Merged via squash.

Prepared head SHA: f243379
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Reviewed-by: @huntharo
Moshiii pushed a commit to Moshiii/openclaw that referenced this pull request Mar 11, 2026
Merged via squash.

Prepared head SHA: f243379
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Reviewed-by: @huntharo
Moshiii pushed a commit to Moshiii/openclaw that referenced this pull request Mar 11, 2026
Merged via squash.

Prepared head SHA: f243379
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Reviewed-by: @huntharo
Ruijie-Ysp pushed a commit to Ruijie-Ysp/clawdbot that referenced this pull request Mar 12, 2026
Merged via squash.

Prepared head SHA: f243379
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Reviewed-by: @huntharo
V-Gutierrez pushed a commit to V-Gutierrez/openclaw-vendor that referenced this pull request Mar 17, 2026
Merged via squash.

Prepared head SHA: f243379
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Reviewed-by: @huntharo
lovewanwan pushed a commit to lovewanwan/openclaw that referenced this pull request Apr 28, 2026
Merged via squash.

Prepared head SHA: f243379
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Reviewed-by: @huntharo
ogt-redknie pushed a commit to ogt-redknie/OPENX that referenced this pull request May 2, 2026
Merged via squash.

Prepared head SHA: f243379
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Reviewed-by: @huntharo
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 9, 2026
Merged via squash.

Prepared head SHA: f243379
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Reviewed-by: @huntharo
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling channel: discord Channel integration: discord channel: telegram Channel integration: telegram docs Improvements or additions to documentation gateway Gateway runtime maintainer Maintainer-authored PR size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants