diff --git a/CHANGELOG.md b/CHANGELOG.md index b22d2430959..6eb5dd7193a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,6 +111,8 @@ Docs: https://docs.openclaw.ai - Docs/Docker images: clarify the official GHCR image source and tag guidance (`main`, `latest`, ``), and document that `OPENCLAW_IMAGE` skips local image builds but still uses the repo-local compose/setup flow. (#27214, #31180) Fixes #15655. Thanks @ipl31. - Agents/Model fallback: classify additional network transport errors (`ECONNREFUSED`, `ENETUNREACH`, `EHOSTUNREACH`, `ENETRESET`, `EAI_AGAIN`) as failover-worthy so fallback chains advance when primary providers are unreachable. Landed from contributor PR #19077 by @ayanesakura. Thanks @ayanesakura. - Agents/Copilot token refresh: refresh GitHub Copilot runtime API tokens after auth-expiry failures and re-run with the renewed token so long-running embedded/subagent turns do not fail on mid-session 401 expiry. Landed from contributor PR #8805 by @Arthur742Ramos. Thanks @Arthur742Ramos. +- Agents/Subagents delivery params: reject unsupported `sessions_spawn` channel-delivery params (`target`, `channel`, `to`, `threadId`, `replyTo`, `transport`) with explicit input errors so delivery intent does not silently leak output to the parent conversation. (#31000) +- Telegram/Multi-account fallback isolation: fail closed for non-default Telegram accounts when route resolution falls back to `matchedBy=default`, preventing cross-account DM/session contamination without explicit account bindings. (#31110) - Discord/Allowlist diagnostics: add debug logs for guild/channel allowlist drops so operators can quickly identify ignored inbound messages and required allowlist entries. Landed from contributor PR #30966 by @haosenwang1018. Thanks @haosenwang1018. - Discord/Ack reactions: add Discord-account-level `ackReactionScope` override and support explicit `off`/`none` values in shared config schemas to disable ack reactions per account. Landed from contributor PR #30400 by @BlueBirdBack. Thanks @BlueBirdBack. - Discord/Forum thread tags: support `appliedTags` on Discord thread-create actions and map to `applied_tags` for forum/media starter posts, with targeted thread-creation regression coverage. Landed from contributor PR #30358 by @pushkarsingh32. Thanks @pushkarsingh32. diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index d5b4bfd8ce2..6d292a4a933 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -91,6 +91,7 @@ Tool params: - `mode: "session"` requires `thread: true` - `cleanup?` (`delete|keep`, default `keep`) - `sandbox?` (`inherit|require`, default `inherit`; `require` rejects spawn unless target child runtime is sandboxed) +- `sessions_spawn` does **not** accept channel-delivery params (`target`, `channel`, `to`, `threadId`, `replyTo`, `transport`). For delivery, use `message`/`sessions_send` from the spawned run. ## Thread-bound sessions diff --git a/src/agents/tools/sessions-spawn-tool.test.ts b/src/agents/tools/sessions-spawn-tool.test.ts index 3414726ec11..94901727340 100644 --- a/src/agents/tools/sessions-spawn-tool.test.ts +++ b/src/agents/tools/sessions-spawn-tool.test.ts @@ -136,4 +136,26 @@ describe("sessions_spawn tool", () => { ); expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled(); }); + + it.each(["target", "transport", "channel", "to", "threadId", "thread_id", "replyTo", "reply_to"])( + "rejects unsupported routing parameter %s", + async (key) => { + const tool = createSessionsSpawnTool({ + agentSessionKey: "agent:main:main", + agentChannel: "discord", + agentAccountId: "default", + agentTo: "channel:123", + agentThreadId: "456", + }); + + await expect( + tool.execute("call-unsupported-param", { + task: "build feature", + [key]: "value", + }), + ).rejects.toThrow(`sessions_spawn does not support "${key}"`); + expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled(); + expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled(); + }, + ); }); diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 3dccc863e45..84ee6d43ac1 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -4,10 +4,20 @@ import { ACP_SPAWN_MODES, spawnAcpDirect } from "../acp-spawn.js"; import { optionalStringEnum } from "../schema/typebox.js"; import { SUBAGENT_SPAWN_MODES, spawnSubagentDirect } from "../subagent-spawn.js"; import type { AnyAgentTool } from "./common.js"; -import { jsonResult, readStringParam } from "./common.js"; +import { jsonResult, readStringParam, ToolInputError } from "./common.js"; const SESSIONS_SPAWN_RUNTIMES = ["subagent", "acp"] as const; const SESSIONS_SPAWN_SANDBOX_MODES = ["inherit", "require"] as const; +const UNSUPPORTED_SESSIONS_SPAWN_PARAM_KEYS = [ + "target", + "transport", + "channel", + "to", + "threadId", + "thread_id", + "replyTo", + "reply_to", +] as const; const SessionsSpawnToolSchema = Type.Object({ task: Type.String(), @@ -47,6 +57,14 @@ export function createSessionsSpawnTool(opts?: { parameters: SessionsSpawnToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; + const unsupportedParam = UNSUPPORTED_SESSIONS_SPAWN_PARAM_KEYS.find((key) => + Object.hasOwn(params, key), + ); + if (unsupportedParam) { + throw new ToolInputError( + `sessions_spawn does not support "${unsupportedParam}". Use "message" or "sessions_send" for channel delivery.`, + ); + } const task = readStringParam(params, "task", { required: true }); const label = typeof params.label === "string" ? params.label.trim() : ""; const runtime = params.runtime === "acp" ? "acp" : "subagent"; diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index fb43e53edfa..495af587261 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -34,7 +34,7 @@ import type { DmPolicy, TelegramGroupConfig, TelegramTopicConfig } from "../conf import { logVerbose, shouldLogVerbose } from "../globals.js"; import { recordChannelActivity } from "../infra/channel-activity.js"; import { resolveAgentRoute } from "../routing/resolve-route.js"; -import { resolveThreadSessionKeys } from "../routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID, resolveThreadSessionKeys } from "../routing/session-key.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { firstDefined, @@ -188,6 +188,17 @@ export const buildTelegramMessageContext = async ({ }, parentPeer, }); + // Fail closed for named Telegram accounts when route resolution falls back to + // default-agent routing. This prevents cross-account DM/session contamination. + if (route.accountId !== DEFAULT_ACCOUNT_ID && route.matchedBy === "default") { + logInboundDrop({ + log: logVerbose, + channel: "telegram", + reason: "non-default account requires explicit binding", + target: route.accountId, + }); + return null; + } const baseSessionKey = route.sessionKey; // DMs: use raw messageThreadId for thread sessions (not forum topic ids) const dmThreadId = threadSpec.scope === "dm" ? threadSpec.id : undefined; diff --git a/src/telegram/bot.create-telegram-bot.test.ts b/src/telegram/bot.create-telegram-bot.test.ts index 4be6b0dcbf3..fbaed5fc651 100644 --- a/src/telegram/bot.create-telegram-bot.test.ts +++ b/src/telegram/bot.create-telegram-bot.test.ts @@ -911,6 +911,39 @@ describe("createTelegramBot", () => { expect(payload.AccountId).toBe("opie"); expect(payload.SessionKey).toBe("agent:opie:main"); }); + + it("drops non-default account DMs without explicit bindings", async () => { + loadConfig.mockReturnValue({ + channels: { + telegram: { + accounts: { + opie: { + botToken: "tok-opie", + dmPolicy: "open", + }, + }, + }, + }, + }); + + createTelegramBot({ token: "tok", accountId: "opie" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 123, type: "private" }, + from: { id: 999, username: "testuser" }, + text: "hello", + date: 1736380800, + message_id: 42, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + it("applies group mention overrides and fallback behavior", async () => { const cases: Array<{ config: Record;