From b12f3ce6e51c074e168e095488b5f0afaa89692a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 28 Mar 2026 05:22:23 +0000 Subject: [PATCH] fix(gateway): support synthetic chat origins --- src/gateway/protocol/schema/logs-chat.ts | 4 + .../chat.directive-tags.test.ts | 79 ++++++++++++++++++ src/gateway/server-methods/chat.ts | 80 ++++++++++++++++++- 3 files changed, 161 insertions(+), 2 deletions(-) diff --git a/src/gateway/protocol/schema/logs-chat.ts b/src/gateway/protocol/schema/logs-chat.ts index 5c4003acb8e..d47dd896cab 100644 --- a/src/gateway/protocol/schema/logs-chat.ts +++ b/src/gateway/protocol/schema/logs-chat.ts @@ -37,6 +37,10 @@ export const ChatSendParamsSchema = Type.Object( message: Type.String(), thinking: Type.Optional(Type.String()), deliver: Type.Optional(Type.Boolean()), + originatingChannel: Type.Optional(Type.String()), + originatingTo: Type.Optional(Type.String()), + originatingAccountId: Type.Optional(Type.String()), + originatingThreadId: Type.Optional(Type.String()), attachments: Type.Optional(Type.Array(Type.Unknown())), timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })), systemInputProvenance: Type.Optional(InputProvenanceSchema), diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index c8c49f7ba53..1d93e5b983a 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -1209,6 +1209,85 @@ describe("chat directive tag stripping for non-streaming final payloads", () => ); }); + it("chat.send accepts admin-scoped synthetic originating routes without external delivery", async () => { + createTranscriptFixture("openclaw-chat-send-synthetic-origin-admin-"); + mockState.finalText = "ok"; + const respond = vi.fn(); + const context = createChatContext(); + + await runNonStreamingChatSend({ + context, + respond, + idempotencyKey: "idem-synthetic-origin-admin", + client: { + connect: { + scopes: ["operator.admin"], + client: { + id: "openclaw-cli", + mode: "cli", + displayName: "openclaw-cli", + version: "1.0.0", + }, + }, + }, + requestParams: { + originatingChannel: "slack", + originatingTo: "D123", + originatingAccountId: "default", + originatingThreadId: "thread-42", + }, + deliver: false, + expectBroadcast: false, + }); + + expect(mockState.lastDispatchCtx).toEqual( + expect.objectContaining({ + OriginatingChannel: "slack", + OriginatingTo: "D123", + ExplicitDeliverRoute: false, + AccountId: "default", + MessageThreadId: "thread-42", + }), + ); + }); + + it("rejects synthetic originating routes when the caller lacks admin scope", async () => { + createTranscriptFixture("openclaw-chat-send-synthetic-origin-reject-"); + mockState.finalText = "ok"; + const respond = vi.fn(); + const context = createChatContext(); + + await runNonStreamingChatSend({ + context, + respond, + idempotencyKey: "idem-synthetic-origin-reject", + client: { + connect: { + scopes: ["operator.write"], + client: { + id: "openclaw-cli", + mode: "cli", + displayName: "openclaw-cli", + version: "1.0.0", + }, + }, + }, + requestParams: { + originatingChannel: "slack", + originatingTo: "D123", + }, + expectBroadcast: false, + waitForCompletion: false, + }); + + const [ok, _payload, error] = respond.mock.calls.at(-1) ?? []; + expect(ok).toBe(false); + expect(error).toMatchObject({ + message: "originating route fields require admin scope", + }); + expect(mockState.lastDispatchCtx).toBeUndefined(); + }); + it("rejects reserved system provenance fields for non-ACP clients", async () => { createTranscriptFixture("openclaw-chat-send-system-provenance-reject-"); mockState.finalText = "ok"; diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 505996b069e..395a79151a2 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -140,6 +140,13 @@ type ChatSendOriginatingRoute = { explicitDeliverRoute: boolean; }; +type ChatSendExplicitOrigin = { + originatingChannel?: string; + originatingTo?: string; + accountId?: string; + messageThreadId?: string; +}; + type SideResultPayload = { kind: "btw"; runId: string; @@ -154,10 +161,22 @@ function resolveChatSendOriginatingRoute(params: { client?: { mode?: string | null; id?: string | null } | null; deliver?: boolean; entry?: ChatSendDeliveryEntry; + explicitOrigin?: ChatSendExplicitOrigin; hasConnectedClient?: boolean; mainKey?: string; sessionKey: string; }): ChatSendOriginatingRoute { + if (params.explicitOrigin?.originatingChannel && params.explicitOrigin.originatingTo) { + return { + originatingChannel: params.explicitOrigin.originatingChannel, + originatingTo: params.explicitOrigin.originatingTo, + ...(params.explicitOrigin.accountId ? { accountId: params.explicitOrigin.accountId } : {}), + ...(params.explicitOrigin.messageThreadId + ? { messageThreadId: params.explicitOrigin.messageThreadId } + : {}), + explicitDeliverRoute: params.deliver === true, + }; + } const shouldDeliverExternally = params.deliver === true; if (!shouldDeliverExternally) { return { @@ -917,6 +936,43 @@ function normalizeOptionalText(value?: string | null): string | undefined { return trimmed || undefined; } +function normalizeExplicitChatSendOrigin( + params: ChatSendExplicitOrigin, +): { ok: true; value?: ChatSendExplicitOrigin } | { ok: false; error: string } { + const originatingChannel = normalizeOptionalText(params.originatingChannel); + const originatingTo = normalizeOptionalText(params.originatingTo); + const accountId = normalizeOptionalText(params.accountId); + const messageThreadId = normalizeOptionalText(params.messageThreadId); + const hasAnyExplicitOriginField = Boolean( + originatingChannel || originatingTo || accountId || messageThreadId, + ); + if (!hasAnyExplicitOriginField) { + return { ok: true }; + } + const normalizedChannel = normalizeMessageChannel(originatingChannel); + if (!normalizedChannel) { + return { + ok: false, + error: "originatingChannel is required when using originating route fields", + }; + } + if (!originatingTo) { + return { + ok: false, + error: "originatingTo is required when using originating route fields", + }; + } + return { + ok: true, + value: { + originatingChannel: normalizedChannel, + originatingTo, + ...(accountId ? { accountId } : {}), + ...(messageThreadId ? { messageThreadId } : {}), + }, + }; +} + function resolveChatAbortRequester( client: GatewayRequestHandlerOptions["client"], ): ChatAbortRequester { @@ -1262,6 +1318,10 @@ export const chatHandlers: GatewayRequestHandlers = { message: string; thinking?: string; deliver?: boolean; + originatingChannel?: string; + originatingTo?: string; + originatingAccountId?: string; + originatingThreadId?: string; attachments?: Array<{ type?: string; mimeType?: string; @@ -1273,14 +1333,29 @@ export const chatHandlers: GatewayRequestHandlers = { systemProvenanceReceipt?: string; idempotencyKey: string; }; + const explicitOriginResult = normalizeExplicitChatSendOrigin({ + originatingChannel: p.originatingChannel, + originatingTo: p.originatingTo, + accountId: p.originatingAccountId, + messageThreadId: p.originatingThreadId, + }); + if (!explicitOriginResult.ok) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, explicitOriginResult.error)); + return; + } if ( - (p.systemInputProvenance || p.systemProvenanceReceipt) && + (p.systemInputProvenance || p.systemProvenanceReceipt || explicitOriginResult.value) && !canInjectSystemProvenance(client) ) { respond( false, undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "system provenance fields require admin scope"), + errorShape( + ErrorCodes.INVALID_REQUEST, + p.systemInputProvenance || p.systemProvenanceReceipt + ? "system provenance fields require admin scope" + : "originating route fields require admin scope", + ), ); return; } @@ -1427,6 +1502,7 @@ export const chatHandlers: GatewayRequestHandlers = { client: clientInfo, deliver: p.deliver, entry, + explicitOrigin: explicitOriginResult.value, hasConnectedClient: client?.connect !== undefined, mainKey: cfg.session?.mainKey, sessionKey,