From 1e7ec8bfd2e7e5e689c6e5b6f1808b3251a5fe0d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 02:43:22 +0000 Subject: [PATCH] fix(routing): preserve explicit cron account and bound message defaults Co-authored-by: lbo728 <72309817+lbo728@users.noreply.github.com> Co-authored-by: stakeswky <64798754+stakeswky@users.noreply.github.com> --- CHANGELOG.md | 1 + src/cron/delivery.test.ts | 18 ++++++++++ src/cron/delivery.ts | 13 +++++++ .../isolated-agent/delivery-target.test.ts | 35 +++++++++++++++++++ src/cron/isolated-agent/delivery-target.ts | 6 ++++ src/cron/isolated-agent/run.ts | 1 + src/cron/types.ts | 1 + src/gateway/protocol/schema/cron.ts | 1 + .../outbound/message-action-runner.test.ts | 28 +++++++++++++++ src/infra/outbound/message-action-runner.ts | 11 +++++- 10 files changed, 114 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a46b277942..7c605803a44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai - Security/Workspace FS: reject hardlinked workspace file aliases in `tools.fs.workspaceOnly` and `tools.exec.applyPatch.workspaceOnly` boundary checks (including sandbox mount-root guards) to prevent out-of-workspace read/write via in-workspace hardlink paths. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/Browser temp paths: harden trace/download output-path handling against symlink-root and symlink-parent escapes with realpath-based write-path checks plus secure fallback tmp-dir validation that fails closed on unsafe fallback links. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Gateway/Message media roots: thread `agentId` through gateway `send` RPC and prefer explicit `agentId` over session/default resolution so non-default agent workspace media sends no longer fail with `LocalMediaAccessError`; added regression coverage for agent precedence and blank-agent fallback. (#23249) Thanks @Sid-Qin. +- Cron/Message multi-account routing: honor explicit `delivery.accountId` for isolated cron delivery resolution, and when `message.send` omits `accountId`, fall back to the sending agent's bound channel account instead of defaulting to the global account. (#27015, #26975) Thanks @lbo728 and @stakeswky. - Cron/Model overrides: when isolated `payload.model` is no longer allowlisted, fall back to default model selection instead of failing the job, while still returning explicit errors for invalid model strings. (#26717) Thanks @Youyou972. - Security/Gateway auth: require pairing for operator device-identity sessions authenticated with shared token auth so unpaired devices cannot self-assign operator scopes. Thanks @tdjackey for reporting. - Security/Gateway WebSocket auth: enforce origin checks for direct browser WebSocket clients beyond Control UI/Webchat, apply password-auth failure throttling to browser-origin loopback attempts (including localhost), and block silent auto-pairing for non-Control-UI browser clients to prevent cross-origin brute-force and session takeover chains. This ships in the next npm release (`2026.2.25`). Thanks @luz-oasis for reporting. diff --git a/src/cron/delivery.test.ts b/src/cron/delivery.test.ts index 6eaa5c66707..495e99d0039 100644 --- a/src/cron/delivery.test.ts +++ b/src/cron/delivery.test.ts @@ -54,4 +54,22 @@ describe("resolveCronDeliveryPlan", () => { expect(plan.channel).toBeUndefined(); expect(plan.to).toBe("https://example.invalid/cron"); }); + + it("threads delivery.accountId when explicitly configured", () => { + const plan = resolveCronDeliveryPlan( + makeJob({ + delivery: { + mode: "announce", + channel: "telegram", + to: "123", + accountId: " bot-a ", + }, + }), + ); + expect(plan.mode).toBe("announce"); + expect(plan.requested).toBe(true); + expect(plan.channel).toBe("telegram"); + expect(plan.to).toBe("123"); + expect(plan.accountId).toBe("bot-a"); + }); }); diff --git a/src/cron/delivery.ts b/src/cron/delivery.ts index 377cdb49b2f..9022d09fd5f 100644 --- a/src/cron/delivery.ts +++ b/src/cron/delivery.ts @@ -4,6 +4,7 @@ export type CronDeliveryPlan = { mode: CronDeliveryMode; channel?: CronMessageChannel; to?: string; + accountId?: string; source: "delivery" | "payload"; requested: boolean; }; @@ -27,6 +28,14 @@ function normalizeTo(value: unknown): string | undefined { return trimmed ? trimmed : undefined; } +function normalizeAccountId(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan { const payload = job.payload.kind === "agentTurn" ? job.payload : null; const delivery = job.delivery; @@ -50,6 +59,9 @@ export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan { (delivery as { channel?: unknown } | undefined)?.channel, ); const deliveryTo = normalizeTo((delivery as { to?: unknown } | undefined)?.to); + const deliveryAccountId = normalizeAccountId( + (delivery as { accountId?: unknown } | undefined)?.accountId, + ); const channel = deliveryChannel ?? payloadChannel ?? "last"; const to = deliveryTo ?? payloadTo; @@ -59,6 +71,7 @@ export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan { mode: resolvedMode, channel: resolvedMode === "announce" ? channel : undefined, to, + accountId: deliveryAccountId, source: "delivery", requested: resolvedMode === "announce", }; diff --git a/src/cron/isolated-agent/delivery-target.test.ts b/src/cron/isolated-agent/delivery-target.test.ts index ad1df42bb47..b28239adda8 100644 --- a/src/cron/isolated-agent/delivery-target.test.ts +++ b/src/cron/isolated-agent/delivery-target.test.ts @@ -299,4 +299,39 @@ describe("resolveDeliveryTarget", () => { expect(result.to).toBe("987654"); expect(result.ok).toBe(true); }); + + it("explicit delivery.accountId overrides session-derived accountId", async () => { + setMainSessionEntry({ + sessionId: "sess-5", + updatedAt: 1000, + lastChannel: "telegram", + lastTo: "chat-999", + lastAccountId: "default", + }); + + const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, { + channel: "telegram", + to: "chat-999", + accountId: "bot-b", + }); + + expect(result.ok).toBe(true); + expect(result.accountId).toBe("bot-b"); + }); + + it("explicit delivery.accountId overrides bindings-derived accountId", async () => { + setMainSessionEntry(undefined); + const cfg = makeCfg({ + bindings: [{ agentId: AGENT_ID, match: { channel: "telegram", accountId: "bound" } }], + }); + + const result = await resolveDeliveryTarget(cfg, AGENT_ID, { + channel: "telegram", + to: "chat-777", + accountId: "explicit", + }); + + expect(result.ok).toBe(true); + expect(result.accountId).toBe("explicit"); + }); }); diff --git a/src/cron/isolated-agent/delivery-target.ts b/src/cron/isolated-agent/delivery-target.ts index 0aa26188120..1af69ee027a 100644 --- a/src/cron/isolated-agent/delivery-target.ts +++ b/src/cron/isolated-agent/delivery-target.ts @@ -43,6 +43,7 @@ export async function resolveDeliveryTarget( channel?: "last" | ChannelId; to?: string; sessionKey?: string; + accountId?: string; }, ): Promise { const requestedChannel = typeof jobPayload.channel === "string" ? jobPayload.channel : "last"; @@ -114,6 +115,11 @@ export async function resolveDeliveryTarget( } } + // Explicit delivery account should override inferred session/binding account. + if (jobPayload.accountId) { + accountId = jobPayload.accountId; + } + // Carry threadId when it was explicitly set (from :topic: parsing or config) // or when delivering to the same recipient as the session's last conversation. // Session-derived threadIds are dropped when the target differs to prevent diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index a4a14bc26b8..751ea2bc13e 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -314,6 +314,7 @@ export async function runCronIsolatedAgentTurn(params: { channel: deliveryPlan.channel ?? "last", to: deliveryPlan.to, sessionKey: params.job.sessionKey, + accountId: deliveryPlan.accountId, }); const { formattedTime, timeLine } = resolveCronStyleNow(params.cfg, now); diff --git a/src/cron/types.ts b/src/cron/types.ts index 837cba2168e..4480b22ae6b 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -22,6 +22,7 @@ export type CronDelivery = { mode: CronDeliveryMode; channel?: CronMessageChannel; to?: string; + accountId?: string; bestEffort?: boolean; }; diff --git a/src/gateway/protocol/schema/cron.ts b/src/gateway/protocol/schema/cron.ts index dae3b340d7e..7e0ebe54917 100644 --- a/src/gateway/protocol/schema/cron.ts +++ b/src/gateway/protocol/schema/cron.ts @@ -138,6 +138,7 @@ export const CronPayloadPatchSchema = Type.Union([ const CronDeliverySharedProperties = { channel: Type.Optional(Type.Union([Type.Literal("last"), NonEmptyString])), + accountId: Type.Optional(NonEmptyString), bestEffort: Type.Optional(Type.Boolean()), }; diff --git a/src/infra/outbound/message-action-runner.test.ts b/src/infra/outbound/message-action-runner.test.ts index 6fdec33ab49..cf3ddabcead 100644 --- a/src/infra/outbound/message-action-runner.test.ts +++ b/src/infra/outbound/message-action-runner.test.ts @@ -1021,4 +1021,32 @@ describe("runMessageAction accountId defaults", () => { expect(ctx.accountId).toBe("ops"); expect(ctx.params.accountId).toBe("ops"); }); + + it("falls back to the agent's bound account when accountId is omitted", async () => { + await runMessageAction({ + cfg: { + bindings: [{ agentId: "agent-b", match: { channel: "discord", accountId: "account-b" } }], + } as OpenClawConfig, + action: "send", + params: { + channel: "discord", + target: "channel:123", + message: "hi", + }, + agentId: "agent-b", + }); + + expect(handleAction).toHaveBeenCalled(); + const ctx = (handleAction.mock.calls as unknown as Array<[unknown]>)[0]?.[0] as + | { + accountId?: string | null; + params: Record; + } + | undefined; + if (!ctx) { + throw new Error("expected action context"); + } + expect(ctx.accountId).toBe("account-b"); + expect(ctx.params.accountId).toBe("account-b"); + }); }); diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 57032e27de8..2693d110306 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -14,6 +14,8 @@ import type { } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; +import { buildChannelAccountBindings } from "../../routing/bindings.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; import { isDeliverableMessageChannel, normalizeMessageChannel, @@ -753,7 +755,14 @@ export async function runMessageAction( } const channel = await resolveChannel(cfg, params); - const accountId = readStringParam(params, "accountId") ?? input.defaultAccountId; + let accountId = readStringParam(params, "accountId") ?? input.defaultAccountId; + if (!accountId && resolvedAgentId) { + const byAgent = buildChannelAccountBindings(cfg).get(channel); + const boundAccountIds = byAgent?.get(normalizeAgentId(resolvedAgentId)); + if (boundAccountIds && boundAccountIds.length > 0) { + accountId = boundAccountIds[0]; + } + } if (accountId) { params.accountId = accountId; }