fix(gateway): support synthetic chat origins

This commit is contained in:
Peter Steinberger 2026-03-28 05:22:23 +00:00
parent 26789db868
commit b12f3ce6e5
No known key found for this signature in database
3 changed files with 161 additions and 2 deletions

View File

@ -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),

View File

@ -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";

View File

@ -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,