Skip to content

Commit 150ca2f

Browse files
yetvalPeter Steinberger
andauthored
fix(agents): keep merged delivery routes account-bound (#98240)
* fix(agents): keep merged delivery routes account-bound mergeDeliveryContext gated route-field crossing on channel only, so a completion origin that knew its account but not a concrete target inherited a different account's to/threadId on the same channel. A subagent, cron, or media completion for bot-a could be addressed to bot-b's chat but sent through bot-a (cross-account misroute) or dropped. This restores the account-bound guard added in 1ed8592 and removed as collateral by 025db6c (PR #89949); same-account and missing-account merges still backfill so the media route-pin path is preserved. Restores the deleted regression test. * fix(agents): centralize account-bound completion routes --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent 35af831 commit 150ca2f

4 files changed

Lines changed: 65 additions & 34 deletions

File tree

src/agents/subagent-announce-delivery.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,27 @@ async function deliverSlackChannelAnnouncement(params: {
395395
}
396396

397397
describe("resolveAnnounceOrigin threaded route targets", () => {
398+
it("does not inherit a target or thread from another account on the same channel", () => {
399+
expect(
400+
resolveAnnounceOrigin(
401+
{
402+
lastChannel: "telegram",
403+
lastTo: "peer-b",
404+
lastAccountId: "bot-b",
405+
lastThreadId: 99,
406+
},
407+
{
408+
channel: "telegram",
409+
accountId: "bot-a",
410+
},
411+
),
412+
).toEqual({
413+
channel: "telegram",
414+
to: undefined,
415+
accountId: "bot-a",
416+
});
417+
});
418+
398419
it("preserves stored thread ids when requester origin omits one for the same chat", () => {
399420
expect(
400421
resolveAnnounceOrigin(

src/agents/tools/media-generate-background-shared.ts

Lines changed: 5 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,7 @@ import {
1919
resolveRequiredCompletionDeliveryFailureTerminalResult,
2020
type RequiredCompletionTerminalResult,
2121
} from "../../tasks/task-completion-contract.js";
22-
import {
23-
deliveryContextFromSession,
24-
normalizeDeliveryContext,
25-
type DeliveryContext,
26-
} from "../../utils/delivery-context.js";
27-
import type { DeliveryContextSessionSource } from "../../utils/delivery-context.types.js";
22+
import { normalizeDeliveryContext, type DeliveryContext } from "../../utils/delivery-context.js";
2823
import {
2924
INTERNAL_MESSAGE_CHANNEL,
3025
isDeliverableMessageChannel,
@@ -65,21 +60,6 @@ export type MediaGenerateBackgroundScheduler = (work: () => Promise<void>) => vo
6560
/** Optional callback invoked when async media generation starts. */
6661
export type MediaGenerateAsyncStartCallback = (message: string) => Promise<void> | void;
6762

68-
function resolvePinnedMediaRequesterOrigin(params: {
69-
requesterOrigin?: DeliveryContext;
70-
sessionEntry?: DeliveryContextSessionSource;
71-
}): DeliveryContext | undefined {
72-
const requesterOrigin = normalizeDeliveryContext(params.requesterOrigin);
73-
const sessionOrigin = deliveryContextFromSession(params.sessionEntry);
74-
const accountsConflict =
75-
requesterOrigin?.accountId &&
76-
sessionOrigin?.accountId &&
77-
requesterOrigin.accountId !== sessionOrigin.accountId;
78-
return accountsConflict
79-
? requesterOrigin
80-
: resolveAnnounceOrigin(params.sessionEntry, requesterOrigin);
81-
}
82-
8363
/** Returns whether a media generation request should detach for a session. */
8464
export function shouldDetachMediaGenerationTask(sessionKey: string | undefined): boolean {
8565
const normalizedSessionKey = sessionKey?.trim();
@@ -168,10 +148,10 @@ function createMediaGenerationTaskRun(params: {
168148
try {
169149
// Pin the complete requester route when detached work starts. Completion-time
170150
// session state can move to another peer while generation is still running.
171-
const requesterOrigin = resolvePinnedMediaRequesterOrigin({
172-
requesterOrigin: params.requesterOrigin,
173-
sessionEntry: loadRequesterSessionEntry(sessionKey).entry,
174-
});
151+
const requesterOrigin = resolveAnnounceOrigin(
152+
loadRequesterSessionEntry(sessionKey).entry,
153+
params.requesterOrigin,
154+
);
175155
const task = createRunningTaskRun({
176156
runtime: "cli",
177157
taskKind: params.taskKind,

src/utils/delivery-context.shared.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ export function deliveryContextFromSession(
229229
return normalizeSessionDeliveryFields(source).deliveryContext;
230230
}
231231

232-
/** Merges delivery contexts without mixing target/account/thread fields across channels. */
232+
/** Merges delivery contexts without mixing target/account/thread fields across route owners. */
233233
export function mergeDeliveryContext(
234234
primary?: DeliveryContext,
235235
fallback?: DeliveryContext,
@@ -243,17 +243,22 @@ export function mergeDeliveryContext(
243243
normalizedPrimary?.channel &&
244244
normalizedFallback?.channel &&
245245
normalizedPrimary.channel !== normalizedFallback.channel;
246+
const accountsConflict =
247+
normalizedPrimary?.accountId &&
248+
normalizedFallback?.accountId &&
249+
normalizedPrimary.accountId !== normalizedFallback.accountId;
250+
const routesConflict = channelsConflict || accountsConflict;
246251
return normalizeDeliveryContext({
247-
channel: normalizedPrimary?.channel ?? normalizedFallback?.channel,
248-
// Keep route fields paired to their channel; avoid crossing fields between
249-
// unrelated channels during session context merges.
250-
to: channelsConflict
251-
? normalizedPrimary?.to
252-
: (normalizedPrimary?.to ?? normalizedFallback?.to),
253-
accountId: channelsConflict
252+
channel: accountsConflict
253+
? normalizedPrimary?.channel
254+
: (normalizedPrimary?.channel ?? normalizedFallback?.channel),
255+
// Keep route fields paired to their channel account; crossing either owner
256+
// can address one account's target through another account's credentials.
257+
to: routesConflict ? normalizedPrimary?.to : (normalizedPrimary?.to ?? normalizedFallback?.to),
258+
accountId: routesConflict
254259
? normalizedPrimary?.accountId
255260
: (normalizedPrimary?.accountId ?? normalizedFallback?.accountId),
256-
threadId: channelsConflict
261+
threadId: routesConflict
257262
? normalizedPrimary?.threadId
258263
: (normalizedPrimary?.threadId ?? normalizedFallback?.threadId),
259264
});

src/utils/delivery-context.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,31 @@ describe("delivery context helpers", () => {
5353
});
5454
});
5555

56+
it("does not inherit route fields from a different account on the same channel", () => {
57+
const merged = mergeDeliveryContext(
58+
{ channel: "telegram", accountId: "bot-a" },
59+
{ channel: "telegram", to: "123", accountId: "bot-b", threadId: "99" },
60+
);
61+
62+
expect(merged).toEqual({
63+
channel: "telegram",
64+
to: undefined,
65+
accountId: "bot-a",
66+
});
67+
expect(merged?.threadId).toBeUndefined();
68+
69+
expect(
70+
mergeDeliveryContext(
71+
{ accountId: "bot-a" },
72+
{ channel: "telegram", to: "123", accountId: "bot-b", threadId: "99" },
73+
),
74+
).toEqual({
75+
channel: undefined,
76+
to: undefined,
77+
accountId: "bot-a",
78+
});
79+
});
80+
5681
it("uses fallback route fields when fallback has no channel", () => {
5782
const merged = mergeDeliveryContext(
5883
{ channel: "demo-channel" },

0 commit comments

Comments
 (0)