fix(gateway/approvals): treat turnSourceTo as optional in chat approval bridge (#82132)

canBridgeNoDeviceChatApprovalFromBackend used matchesRequiredString for
turnSourceTo, which returns false when expected is null. Channels without
a recipient concept (webchat, control-ui) leave turnSourceTo null on both
the approval snapshot and the replay params, so every backend
gateway-client replay was rejected with APPROVAL_CLIENT_MISMATCH after
the approval prompt was answered. turnSourceAccountId and turnSourceThreadId
in the same function already use matchesOptionalString for the same reason;
turnSourceTo was missed when PR #78728 added the helper.

Switch to matchesOptionalString so null-on-both-sides matches. Cross-channel
replay protection is preserved by the existing required turnSourceChannel
and sessionKey checks. Added a regression test asserting webchat replay
with null turnSourceTo is accepted.
This commit is contained in:
Otto Deng
2026-05-15 20:09:15 +08:00
committed by Peter Steinberger
parent 1e31bd2ac2
commit 2d91a3b200
3 changed files with 54 additions and 1 deletions

View File

@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Gateway/approvals: treat `turnSourceTo` as optional in `canBridgeNoDeviceChatApprovalFromBackend`, matching the existing optional handling of `turnSourceAccountId` and `turnSourceThreadId`. Channels without a recipient concept (webchat, control-ui) leave `turnSourceTo` null on both the approval snapshot and the replay params, so the prior required-string check rejected every backend replay with `APPROVAL_CLIENT_MISMATCH`. Cross-channel replay is still gated by the required `turnSourceChannel` and `sessionKey` checks. Fixes #82132. (#82136) Thanks @ottodeng.
- Cron: load runtime plugins before isolated cron model and delivery resolution so external channels can be selected for scheduled runs. (#82111) Thanks @medns.
- Twitch: keep gateway accounts running until shutdown instead of treating successful monitor startup as a clean channel exit, preventing immediate auto-restart loops. Fixes #60071. (#81853) Thanks @edenfunf.
- Agents/auto-reply: honor `agents.defaults.silentReply` and per-surface group silent-reply policy when generic agent-run failure fallbacks decide whether to send visible fallback text. Fixes #82060. (#82086) Thanks @taozengabc.

View File

@@ -689,6 +689,52 @@ describe("sanitizeSystemRunParamsForForwarding", () => {
expectAllowOnceForwardingResult(result);
});
test("accepts trusted backend webchat replay when turnSourceTo is null on both sides (regression #82132)", () => {
const sessionKey = "agent:main:main";
const record = makeChatRecord({
sessionKey,
turnSourceChannel: "webchat",
turnSourceTo: null,
turnSourceAccountId: null,
turnSourceThreadId: null,
systemRunPlan: {
argv: ["echo", "SAFE"],
cwd: null,
commandText: "echo SAFE",
agentId: "main",
sessionKey,
},
systemRunBinding: buildSystemRunApprovalBinding({
argv: ["echo", "SAFE"],
cwd: null,
agentId: "main",
sessionKey,
}).binding,
});
const result = sanitizeSystemRunParamsForForwarding({
rawParams: {
command: ["echo", "SAFE"],
rawCommand: "echo SAFE",
agentId: "main",
sessionKey,
turnSourceChannel: "webchat",
turnSourceTo: null,
turnSourceAccountId: null,
turnSourceThreadId: null,
runId: "approval-1",
approved: true,
approvalDecision: "allow-once",
},
nodeId: "node-1",
client: trustedBackendClient,
execApprovalManager: manager(record),
nowMs: now,
});
expectAllowOnceForwardingResult(result);
});
test("rejects trusted backend chat replay when session binding changes", () => {
const result = sanitizeSystemRunParamsForForwarding({
rawParams: {

View File

@@ -158,7 +158,13 @@ function canBridgeNoDeviceChatApprovalFromBackend(params: {
actual: params.rawParams.turnSourceChannel,
lowercase: true,
}) &&
matchesRequiredString({
// turnSourceTo is channel-specific: required for messaging channels with a
// recipient (e.g. telegram chat id), null for channels without a "to"
// concept (webchat, control-ui). matchesRequiredString returns false on
// null expected, which broke webchat node exec approval replay. Treat it
// as optional so null-on-both-sides matches; required fields below
// (turnSourceChannel, sessionKey) still gate cross-channel replays.
matchesOptionalString({
expected: request.turnSourceTo,
actual: params.rawParams.turnSourceTo,
}) &&