fix(gateway): preserve agentId through gateway send path

Landed from #23249 by @Sid-Qin.
Includes extra regression tests for agentId precedence + blank fallback.

Co-authored-by: Sid <201593046+Sid-Qin@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-02-25 23:30:40 +00:00
parent 125f4071bc
commit 2011edc9e5
8 changed files with 146 additions and 8 deletions

View File

@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
- Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy `oauth.json` onboarding path that exposed the PKCE verifier via OAuth `state`; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (`2026.2.25`). Thanks @zdi-disclosures for reporting.
- Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting.
- Security/Gateway: harden `agents.files` path handling to block out-of-workspace symlink targets for `agents.files.get`/`agents.files.set`, keep in-workspace symlink targets supported, and add gateway regression coverage for both blocked escapes and allowed in-workspace symlinks. 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.
- Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3.
- Security/Nextcloud Talk: stop treating DM pairing-store entries as group allowlist senders, so group authorization remains bounded to configured group allowlists. (#26116) Thanks @bmendonca3.
- Security/IRC: keep pairing-store approvals DM-only and out of IRC group allowlist authorization, with policy regression tests for allowlist resolution. (#26112) Thanks @bmendonca3.

View File

@@ -408,6 +408,7 @@ public struct SendParams: Codable, Sendable {
public let gifplayback: Bool?
public let channel: String?
public let accountid: String?
public let agentid: String?
public let threadid: String?
public let sessionkey: String?
public let idempotencykey: String
@@ -420,6 +421,7 @@ public struct SendParams: Codable, Sendable {
gifplayback: Bool?,
channel: String?,
accountid: String?,
agentid: String?,
threadid: String?,
sessionkey: String?,
idempotencykey: String)
@@ -431,6 +433,7 @@ public struct SendParams: Codable, Sendable {
self.gifplayback = gifplayback
self.channel = channel
self.accountid = accountid
self.agentid = agentid
self.threadid = threadid
self.sessionkey = sessionkey
self.idempotencykey = idempotencykey
@@ -444,6 +447,7 @@ public struct SendParams: Codable, Sendable {
case gifplayback = "gifPlayback"
case channel
case accountid = "accountId"
case agentid = "agentId"
case threadid = "threadId"
case sessionkey = "sessionKey"
case idempotencykey = "idempotencyKey"

View File

@@ -408,6 +408,7 @@ public struct SendParams: Codable, Sendable {
public let gifplayback: Bool?
public let channel: String?
public let accountid: String?
public let agentid: String?
public let threadid: String?
public let sessionkey: String?
public let idempotencykey: String
@@ -420,6 +421,7 @@ public struct SendParams: Codable, Sendable {
gifplayback: Bool?,
channel: String?,
accountid: String?,
agentid: String?,
threadid: String?,
sessionkey: String?,
idempotencykey: String)
@@ -431,6 +433,7 @@ public struct SendParams: Codable, Sendable {
self.gifplayback = gifplayback
self.channel = channel
self.accountid = accountid
self.agentid = agentid
self.threadid = threadid
self.sessionkey = sessionkey
self.idempotencykey = idempotencykey
@@ -444,6 +447,7 @@ public struct SendParams: Codable, Sendable {
case gifplayback = "gifPlayback"
case channel
case accountid = "accountId"
case agentid = "agentId"
case threadid = "threadId"
case sessionkey = "sessionKey"
case idempotencykey = "idempotencyKey"

View File

@@ -22,6 +22,8 @@ export const SendParamsSchema = Type.Object(
gifPlayback: Type.Optional(Type.Boolean()),
channel: Type.Optional(Type.String()),
accountId: Type.Optional(Type.String()),
/** Optional agent id for per-agent media root resolution on gateway sends. */
agentId: Type.Optional(Type.String()),
/** Thread id (channel-specific meaning, e.g. Telegram forum topic id). */
threadId: Type.Optional(Type.String()),
/** Optional session key for mirroring delivered output back into the transcript. */

View File

@@ -342,6 +342,96 @@ describe("gateway send mirroring", () => {
);
});
it("uses explicit agentId for delivery when sessionKey is not provided", async () => {
mockDeliverySuccess("m-agent");
await runSend({
to: "channel:C1",
message: "hello",
channel: "slack",
agentId: "work",
idempotencyKey: "idem-agent-explicit",
});
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "work",
mirror: expect.objectContaining({
sessionKey: "agent:work:slack:channel:resolved",
agentId: "work",
}),
}),
);
});
it("uses sessionKey agentId when explicit agentId is omitted", async () => {
mockDeliverySuccess("m-session-agent");
await runSend({
to: "channel:C1",
message: "hello",
channel: "slack",
sessionKey: "agent:work:slack:channel:c1",
idempotencyKey: "idem-session-agent",
});
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "work",
mirror: expect.objectContaining({
sessionKey: "agent:work:slack:channel:c1",
agentId: "work",
}),
}),
);
});
it("prefers explicit agentId over sessionKey agent for delivery and mirror", async () => {
mockDeliverySuccess("m-agent-precedence");
await runSend({
to: "channel:C1",
message: "hello",
channel: "slack",
agentId: "work",
sessionKey: "agent:main:slack:channel:c1",
idempotencyKey: "idem-agent-precedence",
});
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "work",
mirror: expect.objectContaining({
sessionKey: "agent:main:slack:channel:c1",
agentId: "work",
}),
}),
);
});
it("ignores blank explicit agentId and falls back to sessionKey agent", async () => {
mockDeliverySuccess("m-agent-blank");
await runSend({
to: "channel:C1",
message: "hello",
channel: "slack",
agentId: " ",
sessionKey: "agent:work:slack:channel:c1",
idempotencyKey: "idem-agent-blank",
});
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "work",
mirror: expect.objectContaining({
sessionKey: "agent:work:slack:channel:c1",
agentId: "work",
}),
}),
);
});
it("forwards threadId to outbound delivery when provided", async () => {
mockDeliverySuccess("m-thread");

View File

@@ -106,6 +106,7 @@ export const sendHandlers: GatewayRequestHandlers = {
gifPlayback?: boolean;
channel?: string;
accountId?: string;
agentId?: string;
threadId?: string;
sessionKey?: string;
idempotencyKey: string;
@@ -206,13 +207,21 @@ export const sendHandlers: GatewayRequestHandlers = {
typeof request.sessionKey === "string" && request.sessionKey.trim()
? request.sessionKey.trim().toLowerCase()
: undefined;
const derivedAgentId = resolveSessionAgentId({ config: cfg });
const explicitAgentId =
typeof request.agentId === "string" && request.agentId.trim()
? request.agentId.trim()
: undefined;
const sessionAgentId = providedSessionKey
? resolveSessionAgentId({ sessionKey: providedSessionKey, config: cfg })
: undefined;
const defaultAgentId = resolveSessionAgentId({ config: cfg });
const effectiveAgentId = explicitAgentId ?? sessionAgentId ?? defaultAgentId;
// If callers omit sessionKey, derive a target session key from the outbound route.
const derivedRoute = !providedSessionKey
? await resolveOutboundSessionRoute({
cfg,
channel,
agentId: derivedAgentId,
agentId: effectiveAgentId,
accountId,
target: resolved.to,
threadId,
@@ -221,7 +230,7 @@ export const sendHandlers: GatewayRequestHandlers = {
if (derivedRoute) {
await ensureOutboundSessionEntry({
cfg,
agentId: derivedAgentId,
agentId: effectiveAgentId,
channel,
accountId,
route: derivedRoute,
@@ -233,23 +242,21 @@ export const sendHandlers: GatewayRequestHandlers = {
to: resolved.to,
accountId,
payloads: [{ text: message, mediaUrl, mediaUrls }],
agentId: providedSessionKey
? resolveSessionAgentId({ sessionKey: providedSessionKey, config: cfg })
: derivedAgentId,
agentId: effectiveAgentId,
gifPlayback: request.gifPlayback,
threadId: threadId ?? null,
deps: outboundDeps,
mirror: providedSessionKey
? {
sessionKey: providedSessionKey,
agentId: resolveSessionAgentId({ sessionKey: providedSessionKey, config: cfg }),
agentId: effectiveAgentId,
text: mirrorText || message,
mediaUrls: mirrorMediaUrls.length > 0 ? mirrorMediaUrls : undefined,
}
: derivedRoute
? {
sessionKey: derivedRoute.sessionKey,
agentId: derivedAgentId,
agentId: effectiveAgentId,
text: mirrorText || message,
mediaUrls: mirrorMediaUrls.length > 0 ? mirrorMediaUrls : undefined,
}

View File

@@ -194,6 +194,35 @@ describe("gateway url override hardening", () => {
}),
);
});
it("forwards explicit agentId in gateway send params", async () => {
setRegistry(
createTestRegistry([
{
pluginId: "mattermost",
source: "test",
plugin: {
...createMattermostLikePlugin({ onSendText: () => {} }),
outbound: { deliveryMode: "gateway" },
},
},
]),
);
callGatewayMock.mockResolvedValueOnce({ messageId: "m-agent" });
await sendMessage({
cfg: {},
to: "channel:town-square",
content: "hi",
channel: "mattermost",
agentId: "work",
});
const call = callGatewayMock.mock.calls[0]?.[0] as {
params?: Record<string, unknown>;
};
expect(call.params?.agentId).toBe("work");
});
});
const emptyRegistry = createTestRegistry([]);

View File

@@ -251,6 +251,7 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
mediaUrls: mirrorMediaUrls.length ? mirrorMediaUrls : params.mediaUrls,
gifPlayback: params.gifPlayback,
accountId: params.accountId,
agentId: params.agentId,
channel,
sessionKey: params.mirror?.sessionKey,
idempotencyKey: params.idempotencyKey ?? randomIdempotencyKey(),