mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-31 20:01:36 +00:00
fix(gateway): support synthetic chat origins
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user