diff --git a/CHANGELOG.md b/CHANGELOG.md index deaf82d99..5fc9dd69c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ Docs: https://docs.openclaw.ai ### Fixes +- Security/Plugin channel HTTP auth: normalize protected `/api/channels` path checks against canonicalized request paths (case + percent-decoding) so unauthenticated alternate-path variants cannot bypass gateway auth. +- Security/Exec approvals forwarding: prefer turn-source channel/account/thread metadata when resolving approval delivery targets so stale session routes do not misroute approval prompts. - Auto-reply/Streaming: suppress only exact `NO_REPLY` final replies while still filtering streaming partial sentinel fragments (`NO_`, `NO_RE`, `HEARTBEAT_...`) so substantive replies ending with `NO_REPLY` are delivered and partial silent tokens do not leak during streaming. (#19576) Thanks @aldoeliacim. - Doctor/State integrity: ignore metadata-only slash routing sessions when checking recent missing transcripts so `openclaw doctor` no longer reports false-positive transcript-missing warnings for `*:slash:*` keys. (#27375) thanks @gumadeiras. - Channels/Multi-account config: when adding a non-default channel account to a single-account top-level channel setup, move existing account-scoped top-level single-account values into `channels..accounts.default` before writing the new account so the original account keeps working without duplicated account values at channel root; `openclaw doctor --fix` now repairs previously mixed channel account shapes the same way. (#27334) thanks @gumadeiras. diff --git a/src/agents/bash-tools.exec-approval-request.test.ts b/src/agents/bash-tools.exec-approval-request.test.ts index c14a3f62b..7911b9bdf 100644 --- a/src/agents/bash-tools.exec-approval-request.test.ts +++ b/src/agents/bash-tools.exec-approval-request.test.ts @@ -40,6 +40,10 @@ describe("requestExecApprovalDecision", () => { agentId: "main", resolvedPath: "/usr/bin/echo", sessionKey: "session", + turnSourceChannel: "whatsapp", + turnSourceTo: "+15555550123", + turnSourceAccountId: "work", + turnSourceThreadId: "1739201675.123", }); expect(result).toBe("allow-once"); @@ -57,6 +61,10 @@ describe("requestExecApprovalDecision", () => { agentId: "main", resolvedPath: "/usr/bin/echo", sessionKey: "session", + turnSourceChannel: "whatsapp", + turnSourceTo: "+15555550123", + turnSourceAccountId: "work", + turnSourceThreadId: "1739201675.123", timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, twoPhase: true, }, diff --git a/src/agents/bash-tools.exec-approval-request.ts b/src/agents/bash-tools.exec-approval-request.ts index cda30757e..9eb2eeb83 100644 --- a/src/agents/bash-tools.exec-approval-request.ts +++ b/src/agents/bash-tools.exec-approval-request.ts @@ -17,6 +17,10 @@ export type RequestExecApprovalDecisionParams = { agentId?: string; resolvedPath?: string; sessionKey?: string; + turnSourceChannel?: string; + turnSourceTo?: string; + turnSourceAccountId?: string; + turnSourceThreadId?: string | number; }; type ParsedDecision = { present: boolean; value: string | null }; @@ -72,6 +76,10 @@ export async function registerExecApprovalRequest( agentId: params.agentId, resolvedPath: params.resolvedPath, sessionKey: params.sessionKey, + turnSourceChannel: params.turnSourceChannel, + turnSourceTo: params.turnSourceTo, + turnSourceAccountId: params.turnSourceAccountId, + turnSourceThreadId: params.turnSourceThreadId, timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, twoPhase: true, }, @@ -127,6 +135,10 @@ export async function requestExecApprovalDecisionForHost(params: { agentId?: string; resolvedPath?: string; sessionKey?: string; + turnSourceChannel?: string; + turnSourceTo?: string; + turnSourceAccountId?: string; + turnSourceThreadId?: string | number; }): Promise { return await requestExecApprovalDecision({ id: params.approvalId, @@ -140,6 +152,10 @@ export async function requestExecApprovalDecisionForHost(params: { agentId: params.agentId, resolvedPath: params.resolvedPath, sessionKey: params.sessionKey, + turnSourceChannel: params.turnSourceChannel, + turnSourceTo: params.turnSourceTo, + turnSourceAccountId: params.turnSourceAccountId, + turnSourceThreadId: params.turnSourceThreadId, }); } @@ -155,6 +171,10 @@ export async function registerExecApprovalRequestForHost(params: { agentId?: string; resolvedPath?: string; sessionKey?: string; + turnSourceChannel?: string; + turnSourceTo?: string; + turnSourceAccountId?: string; + turnSourceThreadId?: string | number; }): Promise { return await registerExecApprovalRequest({ id: params.approvalId, @@ -168,5 +188,9 @@ export async function registerExecApprovalRequestForHost(params: { agentId: params.agentId, resolvedPath: params.resolvedPath, sessionKey: params.sessionKey, + turnSourceChannel: params.turnSourceChannel, + turnSourceTo: params.turnSourceTo, + turnSourceAccountId: params.turnSourceAccountId, + turnSourceThreadId: params.turnSourceThreadId, }); } diff --git a/src/agents/bash-tools.exec-host-gateway.ts b/src/agents/bash-tools.exec-host-gateway.ts index 607119109..9ce27e077 100644 --- a/src/agents/bash-tools.exec-host-gateway.ts +++ b/src/agents/bash-tools.exec-host-gateway.ts @@ -44,6 +44,10 @@ export type ProcessGatewayAllowlistParams = { safeBinProfiles: Readonly>; agentId?: string; sessionKey?: string; + turnSourceChannel?: string; + turnSourceTo?: string; + turnSourceAccountId?: string; + turnSourceThreadId?: string | number; scopeKey?: string; warnings: string[]; notifySessionKey?: string; @@ -159,6 +163,10 @@ export async function processGatewayAllowlist( agentId: params.agentId, resolvedPath, sessionKey: params.sessionKey, + turnSourceChannel: params.turnSourceChannel, + turnSourceTo: params.turnSourceTo, + turnSourceAccountId: params.turnSourceAccountId, + turnSourceThreadId: params.turnSourceThreadId, }); expiresAtMs = registration.expiresAtMs; preResolvedDecision = registration.finalDecision; diff --git a/src/agents/bash-tools.exec-host-node.ts b/src/agents/bash-tools.exec-host-node.ts index 47f2931b9..2cedd9850 100644 --- a/src/agents/bash-tools.exec-host-node.ts +++ b/src/agents/bash-tools.exec-host-node.ts @@ -35,6 +35,10 @@ export type ExecuteNodeHostCommandParams = { requestedNode?: string; boundNode?: string; sessionKey?: string; + turnSourceChannel?: string; + turnSourceTo?: string; + turnSourceAccountId?: string; + turnSourceThreadId?: string | number; agentId?: string; security: ExecSecurity; ask: ExecAsk; @@ -202,6 +206,10 @@ export async function executeNodeHostCommand( ask: hostAsk, agentId: params.agentId, sessionKey: params.sessionKey, + turnSourceChannel: params.turnSourceChannel, + turnSourceTo: params.turnSourceTo, + turnSourceAccountId: params.turnSourceAccountId, + turnSourceThreadId: params.turnSourceThreadId, }); expiresAtMs = registration.expiresAtMs; preResolvedDecision = registration.finalDecision; diff --git a/src/agents/bash-tools.exec-types.ts b/src/agents/bash-tools.exec-types.ts index 24227a134..bef8ea4bf 100644 --- a/src/agents/bash-tools.exec-types.ts +++ b/src/agents/bash-tools.exec-types.ts @@ -21,6 +21,9 @@ export type ExecToolDefaults = { scopeKey?: string; sessionKey?: string; messageProvider?: string; + currentChannelId?: string; + currentThreadTs?: string; + accountId?: string; notifyOnExit?: boolean; notifyOnExitEmptySuccess?: boolean; cwd?: string; diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index fac68eb82..105815cf3 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -407,6 +407,10 @@ export function createExecTool( requestedNode: params.node?.trim(), boundNode: defaults?.node?.trim(), sessionKey: defaults?.sessionKey, + turnSourceChannel: defaults?.messageProvider, + turnSourceTo: defaults?.currentChannelId, + turnSourceAccountId: defaults?.accountId, + turnSourceThreadId: defaults?.currentThreadTs, agentId, security, ask, @@ -433,6 +437,10 @@ export function createExecTool( safeBinProfiles, agentId, sessionKey: defaults?.sessionKey, + turnSourceChannel: defaults?.messageProvider, + turnSourceTo: defaults?.currentChannelId, + turnSourceAccountId: defaults?.accountId, + turnSourceThreadId: defaults?.currentThreadTs, scopeKey: defaults?.scopeKey, warnings, notifySessionKey, diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index d07f1d06d..22140d167 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -116,6 +116,10 @@ export function createOpenClawTools(options?: { createCanvasTool({ config: options?.config }), createNodesTool({ agentSessionKey: options?.agentSessionKey, + agentChannel: options?.agentChannel, + agentAccountId: options?.agentAccountId, + currentChannelId: options?.currentChannelId, + currentThreadTs: options?.currentThreadTs, config: options?.config, }), createCronTool({ diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 15be5766c..a06aba73b 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -401,6 +401,9 @@ export function createOpenClawCodingTools(options?: { scopeKey, sessionKey: options?.sessionKey, messageProvider: options?.messageProvider, + currentChannelId: options?.currentChannelId, + currentThreadTs: options?.currentThreadTs, + accountId: options?.agentAccountId, backgroundMs: options?.exec?.backgroundMs ?? execConfig.backgroundMs, timeoutSec: options?.exec?.timeoutSec ?? execConfig.timeoutSec, approvalRunningNoticeMs: diff --git a/src/agents/tools/nodes-tool.ts b/src/agents/tools/nodes-tool.ts index 25b194033..6b18e7d97 100644 --- a/src/agents/tools/nodes-tool.ts +++ b/src/agents/tools/nodes-tool.ts @@ -20,6 +20,7 @@ import { parseDurationMs } from "../../cli/parse-duration.js"; import type { OpenClawConfig } from "../../config/config.js"; import { formatExecCommand } from "../../infra/system-run-command.js"; import { imageMimeFromFormat } from "../../media/mime.js"; +import type { GatewayMessageChannel } from "../../utils/message-channel.js"; import { resolveSessionAgentId } from "../agent-scope.js"; import { resolveImageSanitizationLimits } from "../image-sanitization.js"; import { optionalStringEnum, stringEnum } from "../schema/typebox.js"; @@ -128,9 +129,17 @@ const NodesToolSchema = Type.Object({ export function createNodesTool(options?: { agentSessionKey?: string; + agentChannel?: GatewayMessageChannel; + agentAccountId?: string; + currentChannelId?: string; + currentThreadTs?: string | number; config?: OpenClawConfig; }): AnyAgentTool { const sessionKey = options?.agentSessionKey?.trim() || undefined; + const turnSourceChannel = options?.agentChannel?.trim() || undefined; + const turnSourceTo = options?.currentChannelId?.trim() || undefined; + const turnSourceAccountId = options?.agentAccountId?.trim() || undefined; + const turnSourceThreadId = options?.currentThreadTs; const agentId = resolveSessionAgentId({ sessionKey: options?.agentSessionKey, config: options?.config, @@ -512,6 +521,10 @@ export function createNodesTool(options?: { host: "node", agentId, sessionKey, + turnSourceChannel, + turnSourceTo, + turnSourceAccountId, + turnSourceThreadId, timeoutMs: APPROVAL_TIMEOUT_MS, }, ); diff --git a/src/gateway/protocol/schema/exec-approvals.ts b/src/gateway/protocol/schema/exec-approvals.ts index 083a445a4..c409f9767 100644 --- a/src/gateway/protocol/schema/exec-approvals.ts +++ b/src/gateway/protocol/schema/exec-approvals.ts @@ -98,6 +98,10 @@ export const ExecApprovalRequestParamsSchema = Type.Object( agentId: Type.Optional(Type.Union([Type.String(), Type.Null()])), resolvedPath: Type.Optional(Type.Union([Type.String(), Type.Null()])), sessionKey: Type.Optional(Type.Union([Type.String(), Type.Null()])), + turnSourceChannel: Type.Optional(Type.Union([Type.String(), Type.Null()])), + turnSourceTo: Type.Optional(Type.Union([Type.String(), Type.Null()])), + turnSourceAccountId: Type.Optional(Type.Union([Type.String(), Type.Null()])), + turnSourceThreadId: Type.Optional(Type.Union([Type.String(), Type.Number(), Type.Null()])), timeoutMs: Type.Optional(Type.Integer({ minimum: 1 })), twoPhase: Type.Optional(Type.Boolean()), }, diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 41d04d5d3..a89e1df08 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -88,6 +88,19 @@ function isCanvasPath(pathname: string): boolean { ); } +function decodePathnameOnce(pathname: string): string { + try { + return decodeURIComponent(pathname); + } catch { + return pathname; + } +} + +function isProtectedPluginChannelPath(pathname: string): boolean { + const normalized = decodePathnameOnce(pathname).toLowerCase(); + return normalized === "/api/channels" || normalized.startsWith("/api/channels/"); +} + function isNodeWsClient(client: GatewayWsClient): boolean { if (client.connect.role === "node") { return true; @@ -493,7 +506,7 @@ export function createGatewayHttpServer(opts: { // Channel HTTP endpoints are gateway-auth protected by default. // Non-channel plugin routes remain plugin-owned and must enforce // their own auth when exposing sensitive functionality. - if (requestPath === "/api/channels" || requestPath.startsWith("/api/channels/")) { + if (isProtectedPluginChannelPath(requestPath)) { const token = getBearerToken(req); const authResult = await authorizeHttpGatewayConnect({ auth: resolvedAuth, diff --git a/src/gateway/server-methods/exec-approval.ts b/src/gateway/server-methods/exec-approval.ts index a9b3db150..84866c354 100644 --- a/src/gateway/server-methods/exec-approval.ts +++ b/src/gateway/server-methods/exec-approval.ts @@ -52,6 +52,10 @@ export function createExecApprovalHandlers( agentId?: string; resolvedPath?: string; sessionKey?: string; + turnSourceChannel?: string; + turnSourceTo?: string; + turnSourceAccountId?: string; + turnSourceThreadId?: string | number; timeoutMs?: number; twoPhase?: boolean; }; @@ -91,6 +95,12 @@ export function createExecApprovalHandlers( agentId: p.agentId ?? null, resolvedPath: p.resolvedPath ?? null, sessionKey: p.sessionKey ?? null, + turnSourceChannel: + typeof p.turnSourceChannel === "string" ? p.turnSourceChannel.trim() || null : null, + turnSourceTo: typeof p.turnSourceTo === "string" ? p.turnSourceTo.trim() || null : null, + turnSourceAccountId: + typeof p.turnSourceAccountId === "string" ? p.turnSourceAccountId.trim() || null : null, + turnSourceThreadId: p.turnSourceThreadId ?? null, }; const record = manager.create(request, timeoutMs, explicitId); record.requestedByConnId = client?.connId ?? null; diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts index b19a6d8c6..02eff6a1f 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -493,6 +493,56 @@ describe("exec approval handlers", () => { expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined); }); + it("forwards turn-source metadata to exec approval forwarding", async () => { + vi.useFakeTimers(); + try { + const manager = new ExecApprovalManager(); + const forwarder = { + handleRequested: vi.fn(async () => false), + handleResolved: vi.fn(async () => {}), + stop: vi.fn(), + }; + const handlers = createExecApprovalHandlers(manager, { forwarder }); + const respond = vi.fn(); + const context = { + broadcast: (_event: string, _payload: unknown) => {}, + hasExecApprovalClients: () => false, + }; + + const requestPromise = requestExecApproval({ + handlers, + respond, + context, + params: { + timeoutMs: 60_000, + turnSourceChannel: "whatsapp", + turnSourceTo: "+15555550123", + turnSourceAccountId: "work", + turnSourceThreadId: "1739201675.123", + }, + }); + for (let idx = 0; idx < 20; idx += 1) { + await Promise.resolve(); + } + expect(forwarder.handleRequested).toHaveBeenCalledTimes(1); + expect(forwarder.handleRequested).toHaveBeenCalledWith( + expect.objectContaining({ + request: expect.objectContaining({ + turnSourceChannel: "whatsapp", + turnSourceTo: "+15555550123", + turnSourceAccountId: "work", + turnSourceThreadId: "1739201675.123", + }), + }), + ); + + await vi.runOnlyPendingTimersAsync(); + await requestPromise; + } finally { + vi.useRealTimers(); + } + }); + it("expires immediately when no approver clients and no forwarding targets", async () => { vi.useFakeTimers(); try { diff --git a/src/gateway/server.plugin-http-auth.test.ts b/src/gateway/server.plugin-http-auth.test.ts index 79093169c..79d9070f9 100644 --- a/src/gateway/server.plugin-http-auth.test.ts +++ b/src/gateway/server.plugin-http-auth.test.ts @@ -242,6 +242,78 @@ describe("gateway plugin HTTP auth boundary", () => { }); }); + test("requires gateway auth for canonicalized /api/channels variants", async () => { + const resolvedAuth: ResolvedGatewayAuth = { + mode: "token", + token: "test-token", + password: undefined, + allowTailscale: false, + }; + + await withTempConfig({ + cfg: { gateway: { trustedProxies: [] } }, + prefix: "openclaw-plugin-http-auth-canonicalized-test-", + run: async () => { + const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { + const pathname = new URL(req.url ?? "/", "http://localhost").pathname; + const canonicalPath = decodeURIComponent(pathname).toLowerCase(); + if (canonicalPath === "/api/channels/nostr/default/profile") { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify({ ok: true, route: "channel-canonicalized" })); + return true; + } + return false; + }); + + const server = createGatewayHttpServer({ + canvasHost: null, + clients: new Set(), + controlUiEnabled: false, + controlUiBasePath: "/__control__", + openAiChatCompletionsEnabled: false, + openResponsesEnabled: false, + handleHooksRequest: async () => false, + handlePluginRequest, + resolvedAuth, + }); + + const unauthenticatedCaseVariant = createResponse(); + await dispatchRequest( + server, + createRequest({ path: "/API/channels/nostr/default/profile" }), + unauthenticatedCaseVariant.res, + ); + expect(unauthenticatedCaseVariant.res.statusCode).toBe(401); + expect(unauthenticatedCaseVariant.getBody()).toContain("Unauthorized"); + expect(handlePluginRequest).not.toHaveBeenCalled(); + + const unauthenticatedEncodedSlash = createResponse(); + await dispatchRequest( + server, + createRequest({ path: "/api/channels%2Fnostr%2Fdefault%2Fprofile" }), + unauthenticatedEncodedSlash.res, + ); + expect(unauthenticatedEncodedSlash.res.statusCode).toBe(401); + expect(unauthenticatedEncodedSlash.getBody()).toContain("Unauthorized"); + expect(handlePluginRequest).not.toHaveBeenCalled(); + + const authenticatedCaseVariant = createResponse(); + await dispatchRequest( + server, + createRequest({ + path: "/API/channels/nostr/default/profile", + authorization: "Bearer test-token", + }), + authenticatedCaseVariant.res, + ); + expect(authenticatedCaseVariant.res.statusCode).toBe(200); + expect(authenticatedCaseVariant.getBody()).toContain('"route":"channel-canonicalized"'); + expect(handlePluginRequest).toHaveBeenCalledTimes(1); + }, + }); + }); + test.each(["0.0.0.0", "::"])( "returns 404 (not 500) for non-hook routes with hooks enabled and bindHost=%s", async (bindHost) => { diff --git a/src/infra/exec-approval-forwarder.test.ts b/src/infra/exec-approval-forwarder.test.ts index 298efa478..8d81cc696 100644 --- a/src/infra/exec-approval-forwarder.test.ts +++ b/src/infra/exec-approval-forwarder.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { createExecApprovalForwarder } from "./exec-approval-forwarder.js"; @@ -40,14 +43,17 @@ function createForwarder(params: { resolveSessionTarget?: () => { channel: string; to: string } | null; }) { const deliver = params.deliver ?? vi.fn().mockResolvedValue([]); - const forwarder = createExecApprovalForwarder({ + const deps: NonNullable[0]> = { getConfig: () => params.cfg, deliver: deliver as unknown as NonNullable< NonNullable[0]>["deliver"] >, nowMs: () => 1000, - resolveSessionTarget: params.resolveSessionTarget ?? (() => null), - }); + }; + if (params.resolveSessionTarget !== undefined) { + deps.resolveSessionTarget = params.resolveSessionTarget; + } + const forwarder = createExecApprovalForwarder(deps); return { deliver, forwarder }; } @@ -212,6 +218,58 @@ describe("exec approval forwarder", () => { }); }); + it("prefers turn-source routing over stale session last route", async () => { + vi.useFakeTimers(); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-exec-approval-forwarder-test-")); + try { + const storePath = path.join(tmpDir, "sessions.json"); + fs.writeFileSync( + storePath, + JSON.stringify({ + "agent:main:main": { + updatedAt: 1, + channel: "slack", + to: "U1", + lastChannel: "slack", + lastTo: "U1", + }, + }), + "utf-8", + ); + + const cfg = { + session: { store: storePath }, + approvals: { exec: { enabled: true, mode: "session" } }, + } as OpenClawConfig; + + const { deliver, forwarder } = createForwarder({ cfg }); + await expect( + forwarder.handleRequested({ + ...baseRequest, + request: { + ...baseRequest.request, + turnSourceChannel: "whatsapp", + turnSourceTo: "+15555550123", + turnSourceAccountId: "work", + turnSourceThreadId: "1739201675.123", + }, + }), + ).resolves.toBe(true); + + expect(deliver).toHaveBeenCalledTimes(1); + expect(deliver).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "whatsapp", + to: "+15555550123", + accountId: "work", + threadId: "1739201675.123", + }), + ); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("can forward resolved notices without pending cache when request payload is present", async () => { vi.useFakeTimers(); const cfg = { diff --git a/src/infra/exec-approval-forwarder.ts b/src/infra/exec-approval-forwarder.ts index 7af7489ba..b296f935b 100644 --- a/src/infra/exec-approval-forwarder.ts +++ b/src/infra/exec-approval-forwarder.ts @@ -8,7 +8,11 @@ import type { import { createSubsystemLogger } from "../logging/subsystem.js"; import { normalizeAccountId, parseAgentSessionKey } from "../routing/session-key.js"; import { compileSafeRegex } from "../security/safe-regex.js"; -import { isDeliverableMessageChannel, normalizeMessageChannel } from "../utils/message-channel.js"; +import { + isDeliverableMessageChannel, + normalizeMessageChannel, + type DeliverableMessageChannel, +} from "../utils/message-channel.js"; import type { ExecApprovalDecision, ExecApprovalRequest, @@ -209,6 +213,11 @@ function buildExpiredMessage(request: ExecApprovalRequest) { return `⏱️ Exec approval expired. ID: ${request.id}`; } +function normalizeTurnSourceChannel(value?: string | null): DeliverableMessageChannel | undefined { + const normalized = value ? normalizeMessageChannel(value) : undefined; + return normalized && isDeliverableMessageChannel(normalized) ? normalized : undefined; +} + function defaultResolveSessionTarget(params: { cfg: OpenClawConfig; request: ExecApprovalRequest; @@ -225,7 +234,14 @@ function defaultResolveSessionTarget(params: { if (!entry) { return null; } - const target = resolveSessionDeliveryTarget({ entry, requestedChannel: "last" }); + const target = resolveSessionDeliveryTarget({ + entry, + requestedChannel: "last", + turnSourceChannel: normalizeTurnSourceChannel(params.request.request.turnSourceChannel), + turnSourceTo: params.request.request.turnSourceTo?.trim() || undefined, + turnSourceAccountId: params.request.request.turnSourceAccountId?.trim() || undefined, + turnSourceThreadId: params.request.request.turnSourceThreadId ?? undefined, + }); if (!target.channel || !target.to) { return null; } diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index d78f3d137..c8e2bf041 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -22,6 +22,10 @@ export type ExecApprovalRequestPayload = { agentId?: string | null; resolvedPath?: string | null; sessionKey?: string | null; + turnSourceChannel?: string | null; + turnSourceTo?: string | null; + turnSourceAccountId?: string | null; + turnSourceThreadId?: string | number | null; }; export type ExecApprovalRequest = {