fix: derive canonicalize agentId from target key, not current session

When a non-default agent (e.g. agent:shopping-claw:main) calls restart or
config/update with sessionKey="main", the gateway treats "main" as
resolveMainSessionKey(cfg) = the default agent's session. Previously,
isTargetingOtherSession canonicalized the target key using the CURRENT
session's agentId, so "main" mapped to the current agent's main session
rather than the default agent's — falsely treating a cross-agent request
as same-session and forwarding the wrong chat's deliveryContext.

Fix: canonicalize each key using its own agentId (resolveAgentIdFromSessionKey
on the key itself). For bare "main", this returns DEFAULT_AGENT_ID so
"main" → "agent:main:main" regardless of which agent is calling. Applied
to both the restart path and the RPC path (resolveGatewayWriteMeta).

Add two regression tests covering the cross-agent alias scenario.
This commit is contained in:
Bryan Marty 2026-03-08 18:09:32 +00:00
parent 3849101443
commit 045bf781a4
No known key found for this signature in database
2 changed files with 88 additions and 5 deletions

View File

@ -167,4 +167,53 @@ describe("createGatewayTool live delivery context guard", () => {
expect(sentinelPayload?.deliveryContext?.channel).toBe("discord");
expect(sentinelPayload?.deliveryContext?.to).toBe("123456789");
});
it("does not forward live RPC delivery context when a non-default agent passes sessionKey='main'", async () => {
// "main" resolves to "agent:main:main" (default agent), which differs from the
// current session "agent:shopping-claw:main". Live context must NOT be forwarded.
mocks.callGatewayTool.mockClear();
const tool = createGatewayTool({
agentSessionKey: "agent:shopping-claw:main",
agentChannel: "discord",
agentTo: "123456789",
});
await execTool(tool, {
action: "config.patch",
raw: '{"key":"value"}',
baseHash: "abc123",
sessionKey: "main", // targets default agent — different from current shopping-claw session
note: "cross-agent patch",
});
const forwardedParams = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
expect(forwardedParams?.deliveryContext).toBeUndefined();
});
it("does not forward live restart context when a non-default agent passes sessionKey='main'", async () => {
// "main" resolves to "agent:main:main" (default agent), which differs from the
// current session "agent:shopping-claw:main". Sentinel must not carry this session's thread.
mocks.writeRestartSentinel.mockClear();
mocks.extractDeliveryInfo.mockReturnValueOnce({
deliveryContext: { channel: "telegram", to: "+19995550001", accountId: undefined },
threadId: undefined,
});
const tool = createGatewayTool({
agentSessionKey: "agent:shopping-claw:main",
agentChannel: "discord",
agentTo: "123456789",
});
await execTool(tool, { action: "restart", sessionKey: "main" });
const sentinelPayload = getCallArg<{ deliveryContext?: { channel?: string; to?: string } }>(
mocks.writeRestartSentinel,
0,
0,
);
// Should fall back to extractDeliveryInfo() for the targeted session, not current session's live context
expect(sentinelPayload?.deliveryContext?.channel).toBe("telegram");
expect(sentinelPayload?.deliveryContext?.to).toBe("+19995550001");
});
});

View File

@ -116,9 +116,29 @@ export function createGatewayTool(opts?: {
// session, the live context belongs to the wrong session and would
// misroute the post-restart reply. Fall back to extractDeliveryInfo()
// so the server uses the correct routing for the target session.
// Canonicalize both keys before comparing so that aliases like "main"
// and "agent:main:main" are treated as the same session. Without this,
// an operator passing sessionKey="main" would be incorrectly treated as
// targeting a different session, suppressing live deliveryContext and
// falling back to the stale session store. See #18612.
const ownKey = opts?.agentSessionKey?.trim() || undefined;
const agentId = resolveAgentIdFromSessionKey(ownKey);
// Canonicalize each key using its OWN agentId — not the current session's.
// If a non-default agent passes sessionKey="main", resolveAgentIdFromSessionKey
// returns DEFAULT_AGENT_ID ("main") so "main" → "agent:main:main". Using the
// current session's agentId instead would map "main" to the current agent's main
// session, falsely treating a cross-agent request as same-session. See #18612.
const canonicalizeOwn = (k: string) =>
canonicalizeMainSessionAlias({ cfg: opts?.config, agentId, sessionKey: k });
const canonicalizeTarget = (k: string) =>
canonicalizeMainSessionAlias({
cfg: opts?.config,
agentId: resolveAgentIdFromSessionKey(k),
sessionKey: k,
});
const isTargetingOtherSession =
explicitSessionKey != null &&
explicitSessionKey !== (opts?.agentSessionKey?.trim() || undefined);
canonicalizeTarget(explicitSessionKey) !== (ownKey ? canonicalizeOwn(ownKey) : undefined);
// Only forward live context when both channel and to are present.
// Forwarding a partial context (channel without to) causes the server
// to write a sentinel without `to`, and scheduleRestartSentinelWake
@ -228,12 +248,26 @@ export function createGatewayTool(opts?: {
? Math.floor(params.restartDelayMs)
: undefined;
// Only forward live context when the target session is this agent's
// own session. When an explicit sessionKey points to a different
// session, omit deliveryContext so the server falls back to
// extractDeliveryInfo(sessionKey) which uses that session's routing.
// own session. Canonicalize both keys before comparing so that aliases
// like "main" and "agent:main:main" are treated as the same session.
// When an explicit sessionKey points to a different session, omit
// deliveryContext so the server falls back to extractDeliveryInfo(sessionKey).
const rpcOwnKey = opts?.agentSessionKey?.trim() || undefined;
const rpcAgentId = resolveAgentIdFromSessionKey(rpcOwnKey);
// Same cross-agent alias fix as the restart path: derive agentId from each key
// independently so that "main" resolves to the default agent, not the current one.
const rpcCanonicalizeOwn = (k: string) =>
canonicalizeMainSessionAlias({ cfg: opts?.config, agentId: rpcAgentId, sessionKey: k });
const rpcCanonicalizeTarget = (k: string) =>
canonicalizeMainSessionAlias({
cfg: opts?.config,
agentId: resolveAgentIdFromSessionKey(k),
sessionKey: k,
});
const isTargetingOtherSession =
explicitSessionKey != null &&
explicitSessionKey !== (opts?.agentSessionKey?.trim() || undefined);
rpcCanonicalizeTarget(explicitSessionKey) !==
(rpcOwnKey ? rpcCanonicalizeOwn(rpcOwnKey) : undefined);
const deliveryContext = isTargetingOtherSession ? undefined : liveDeliveryContextForRpc;
return { sessionKey, note, restartDelayMs, deliveryContext };
};