Fix/telegram writeback admin scope gate (#54561)

* fix(telegram): require operator.admin for legacy target writeback persistence

* Address claude feedback

* Update extensions/telegram/src/target-writeback.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Remove stray brace

* Add updated docs

* Add missing test file, address codex concerns

* Fix test formatting error

* Address comments, fix tests

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Devin Robison
2026-03-25 11:12:09 -07:00
committed by GitHub
parent 89c4c674d1
commit b7d70ade3b
18 changed files with 808 additions and 73 deletions

View File

@@ -141,6 +141,7 @@ export type ChannelOutboundContext = {
identity?: OutboundIdentity;
deps?: OutboundSendDeps;
silent?: boolean;
gatewayClientScopes?: readonly string[];
};
export type ChannelOutboundPayloadContext = ChannelOutboundContext & {

View File

@@ -552,6 +552,7 @@ export type ChannelPollContext = {
threadId?: string | null;
silent?: boolean;
isAnonymous?: boolean;
gatewayClientScopes?: readonly string[];
};
/** Minimal base for all channel probe results. Channel-specific probes extend this. */

View File

@@ -101,26 +101,40 @@ const makeContext = (): GatewayRequestContext =>
}) as unknown as GatewayRequestContext;
async function runSend(params: Record<string, unknown>) {
return await runSendWithClient(params);
}
async function runSendWithClient(
params: Record<string, unknown>,
client?: { connect?: { scopes?: string[] } } | null,
) {
const respond = vi.fn();
await sendHandlers.send({
params: params as never,
respond,
context: makeContext(),
req: { type: "req", id: "1", method: "send" },
client: null,
client: (client ?? null) as never,
isWebchatConnect: () => false,
});
return { respond };
}
async function runPoll(params: Record<string, unknown>) {
return await runPollWithClient(params);
}
async function runPollWithClient(
params: Record<string, unknown>,
client?: { connect?: { scopes?: string[] } } | null,
) {
const respond = vi.fn();
await sendHandlers.poll({
params: params as never,
respond,
context: makeContext(),
req: { type: "req", id: "1", method: "poll" },
client: null,
client: (client ?? null) as never,
isWebchatConnect: () => false,
});
return { respond };
@@ -185,6 +199,48 @@ describe("gateway send mirroring", () => {
);
});
it("forwards gateway client scopes into outbound delivery", async () => {
mockDeliverySuccess("m-telegram-scope");
await runSendWithClient(
{
to: "https://t.me/mychannel",
message: "hi",
channel: "telegram",
idempotencyKey: "idem-telegram-scope",
},
{ connect: { scopes: ["operator.write"] } },
);
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
gatewayClientScopes: ["operator.write"],
}),
);
});
it("forwards an empty gateway scope array into outbound delivery", async () => {
mockDeliverySuccess("m-telegram-empty-scope");
await runSendWithClient(
{
to: "https://t.me/mychannel",
message: "hi",
channel: "telegram",
idempotencyKey: "idem-telegram-empty-scope",
},
{ connect: { scopes: [] } },
);
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
gatewayClientScopes: [],
}),
);
});
it("rejects empty sends when neither text nor media is present", async () => {
const { respond } = await runSend({
to: "channel:C1",
@@ -268,6 +324,48 @@ describe("gateway send mirroring", () => {
);
});
it("forwards gateway client scopes into outbound poll delivery", async () => {
await runPollWithClient(
{
to: "https://t.me/mychannel",
question: "Q?",
options: ["A", "B"],
channel: "telegram",
idempotencyKey: "idem-poll-scope",
},
{ connect: { scopes: ["operator.admin"] } },
);
expect(mocks.sendPoll).toHaveBeenCalledWith(
expect.objectContaining({
cfg: expect.any(Object),
to: "resolved",
gatewayClientScopes: ["operator.admin"],
}),
);
});
it("forwards an empty gateway scope array into outbound poll delivery", async () => {
await runPollWithClient(
{
to: "https://t.me/mychannel",
question: "Q?",
options: ["A", "B"],
channel: "telegram",
idempotencyKey: "idem-poll-empty-scope",
},
{ connect: { scopes: [] } },
);
expect(mocks.sendPoll).toHaveBeenCalledWith(
expect.objectContaining({
cfg: expect.any(Object),
to: "resolved",
gatewayClientScopes: [],
}),
);
});
it("auto-picks the single configured channel for poll", async () => {
const { respond } = await runPoll({
to: "x",

View File

@@ -89,7 +89,7 @@ async function resolveRequestedChannel(params: {
}
export const sendHandlers: GatewayRequestHandlers = {
send: async ({ params, respond, context }) => {
send: async ({ params, respond, context, client }) => {
const p = params;
if (!validateSendParams(p)) {
respond(
@@ -263,6 +263,7 @@ export const sendHandlers: GatewayRequestHandlers = {
gifPlayback: request.gifPlayback,
threadId: threadId ?? null,
deps: outboundDeps,
gatewayClientScopes: client?.connect?.scopes ?? [],
mirror: providedSessionKey
? {
sessionKey: providedSessionKey,
@@ -332,7 +333,7 @@ export const sendHandlers: GatewayRequestHandlers = {
inflightMap.delete(dedupeKey);
}
},
poll: async ({ params, respond, context }) => {
poll: async ({ params, respond, context, client }) => {
const p = params;
if (!validatePollParams(p)) {
respond(
@@ -444,6 +445,7 @@ export const sendHandlers: GatewayRequestHandlers = {
threadId,
silent: request.silent,
isAnonymous: request.isAnonymous,
gatewayClientScopes: client?.connect?.scopes ?? [],
});
const payload: Record<string, unknown> = {
runId: idem,

View File

@@ -0,0 +1,296 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
sendMessageTelegram,
sendPollTelegram,
type TelegramApiOverride,
} from "../../extensions/telegram/src/send.js";
import {
clearConfigCache,
loadConfig,
writeConfigFile,
type OpenClawConfig,
} from "../config/config.js";
import { loadCronStore, saveCronStore } from "../cron/store.js";
import type { CronStoreFile } from "../cron/types.js";
import { createEmptyPluginRegistry } from "../plugins/registry.js";
import {
getActivePluginRegistry,
releasePinnedPluginChannelRegistry,
setActivePluginRegistry,
} from "../plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js";
import { connectOk, installGatewayTestHooks, rpcReq } from "./test-helpers.js";
import { withServer } from "./test-with-server.js";
installGatewayTestHooks({ scope: "suite" });
type TelegramGetChat = NonNullable<TelegramApiOverride["getChat"]>;
type TelegramSendMessage = NonNullable<TelegramApiOverride["sendMessage"]>;
type TelegramSendPoll = NonNullable<TelegramApiOverride["sendPoll"]>;
function createCronStore(): CronStoreFile {
const now = Date.now();
return {
version: 1,
jobs: [
{
id: "telegram-writeback-job",
name: "Telegram writeback job",
enabled: true,
createdAtMs: now,
updatedAtMs: now,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "tick" },
state: {},
delivery: {
mode: "announce",
channel: "telegram",
to: "@mychannel",
},
},
],
};
}
async function withTelegramGatewayWritebackFixture(
run: (params: {
cronStorePath: string;
getChatMock: ReturnType<typeof vi.fn>;
sendMessageMock: ReturnType<typeof vi.fn>;
sendPollMock: ReturnType<typeof vi.fn>;
installTelegramTestPlugin: () => void;
}) => Promise<void>,
): Promise<void> {
const previousRegistry = getActivePluginRegistry() ?? createEmptyPluginRegistry();
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-writeback-"));
const cronStorePath = path.join(tempDir, "cron", "jobs.json");
const getChatMock = vi.fn();
const sendMessageMock = vi.fn();
const sendPollMock = vi.fn();
const getChat: TelegramGetChat = async (...args) => {
getChatMock(...args);
return { id: -100321 } as unknown as Awaited<ReturnType<TelegramGetChat>>;
};
const sendMessage: TelegramSendMessage = async (...args) => {
sendMessageMock(...args);
return {
message_id: 17,
date: 1,
chat: { id: "-100321" },
} as unknown as Awaited<ReturnType<TelegramSendMessage>>;
};
const sendPoll: TelegramSendPoll = async (...args) => {
sendPollMock(...args);
return {
message_id: 19,
date: 1,
chat: { id: "-100321" },
poll: { id: "poll-1" },
} as unknown as Awaited<ReturnType<TelegramSendPoll>>;
};
const installTelegramTestPlugin = () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
source: "test",
plugin: createOutboundTestPlugin({
id: "telegram",
label: "Telegram",
outbound: {
deliveryMode: "direct",
sendText: async ({ cfg, to, text, accountId, gatewayClientScopes }) =>
({
channel: "telegram",
...(await sendMessageTelegram(to, text, {
cfg,
accountId: accountId ?? undefined,
gatewayClientScopes,
token: "123:abc",
api: {
getChat,
sendMessage,
},
})),
}),
sendPoll: async ({ cfg, to, poll, accountId, gatewayClientScopes, threadId }) =>
({
channel: "telegram",
...(await sendPollTelegram(to, poll, {
cfg,
accountId: accountId ?? undefined,
gatewayClientScopes,
messageThreadId:
typeof threadId === "number" && Number.isFinite(threadId)
? Math.trunc(threadId)
: undefined,
token: "123:abc",
api: {
getChat,
sendPoll,
},
})),
}),
},
}),
},
]),
"telegram-target-writeback-scope",
);
};
installTelegramTestPlugin();
try {
await saveCronStore(cronStorePath, createCronStore());
clearConfigCache();
await writeConfigFile({
agents: {
defaults: {
model: "gpt-5.4",
workspace: path.join(process.env.HOME ?? ".", "openclaw"),
},
},
channels: {
telegram: {
botToken: "123:abc",
defaultTo: "https://t.me/mychannel",
},
},
cron: {
store: cronStorePath,
},
} satisfies OpenClawConfig);
clearConfigCache();
await run({
cronStorePath,
getChatMock,
sendMessageMock,
sendPollMock,
installTelegramTestPlugin,
});
} finally {
setActivePluginRegistry(previousRegistry);
clearConfigCache();
await fs.rm(tempDir, { recursive: true, force: true });
}
}
describe("gateway Telegram target writeback scope enforcement", () => {
it("allows operator.write delivery but skips config and cron persistence", async () => {
await withTelegramGatewayWritebackFixture(async (params) => {
const { cronStorePath, getChatMock, sendMessageMock } = params;
await withServer(async (ws) => {
await connectOk(ws, { token: "secret", scopes: ["operator.write"] });
const current = await rpcReq<{ hash?: string }>(ws, "config.get", {});
expect(current.ok).toBe(true);
expect(typeof current.payload?.hash).toBe("string");
const directPatch = await rpcReq(ws, "config.patch", {
raw: JSON.stringify({
channels: {
telegram: {
defaultTo: "-100321",
},
},
}),
baseHash: current.payload?.hash,
});
expect(directPatch.ok).toBe(false);
expect(directPatch.error?.message).toBe("missing scope: operator.admin");
const viaSend = await rpcReq(ws, "send", {
to: "https://t.me/mychannel",
message: "hello from send scope test",
channel: "telegram",
sessionKey: "main",
idempotencyKey: "idem-send-telegram-target-writeback-operator-write",
});
expect(viaSend.ok).toBe(true);
clearConfigCache();
const stored = loadConfig();
const cronStore = await loadCronStore(cronStorePath);
expect(stored.channels?.telegram?.defaultTo).toBe("https://t.me/mychannel");
expect(cronStore.jobs[0]?.delivery?.to).toBe("@mychannel");
expect(getChatMock).toHaveBeenCalledWith("@mychannel");
expect(sendMessageMock).toHaveBeenCalledWith("-100321", "hello from send scope test", {
parse_mode: "HTML",
});
});
});
});
it("persists config and cron rewrites for operator.admin delivery", async () => {
await withTelegramGatewayWritebackFixture(async (params) => {
const { cronStorePath, getChatMock, sendMessageMock } = params;
await withServer(async (ws) => {
await connectOk(ws, { token: "secret", scopes: ["operator.write", "operator.admin"] });
const viaSend = await rpcReq(ws, "send", {
to: "https://t.me/mychannel",
message: "hello from admin scope test",
channel: "telegram",
sessionKey: "main",
idempotencyKey: "idem-send-telegram-target-writeback-operator-admin",
});
expect(viaSend.ok).toBe(true);
clearConfigCache();
const stored = loadConfig();
const cronStore = await loadCronStore(cronStorePath);
expect(stored.channels?.telegram?.defaultTo).toBe("-100321");
expect(cronStore.jobs[0]?.delivery?.to).toBe("-100321");
expect(getChatMock).toHaveBeenCalledWith("@mychannel");
expect(sendMessageMock).toHaveBeenCalledWith("-100321", "hello from admin scope test", {
parse_mode: "HTML",
});
});
});
});
it("allows operator.write poll delivery but skips config and cron persistence", async () => {
await withTelegramGatewayWritebackFixture(async (params) => {
const { cronStorePath, getChatMock, sendPollMock, installTelegramTestPlugin } = params;
await withServer(async (ws) => {
releasePinnedPluginChannelRegistry();
installTelegramTestPlugin();
await connectOk(ws, { token: "secret", scopes: ["operator.write"] });
const viaPoll = await rpcReq(ws, "poll", {
to: "https://t.me/mychannel",
question: "Which one?",
options: ["A", "B"],
channel: "telegram",
idempotencyKey: "idem-poll-telegram-target-writeback-operator-write",
});
if (!viaPoll.ok) {
throw new Error(`poll failed: ${viaPoll.error?.message ?? "unknown error"}`);
}
expect(viaPoll.ok).toBe(true);
clearConfigCache();
const stored = loadConfig();
const cronStore = await loadCronStore(cronStorePath);
expect(stored.channels?.telegram?.defaultTo).toBe("https://t.me/mychannel");
expect(cronStore.jobs[0]?.delivery?.to).toBe("@mychannel");
expect(getChatMock).toHaveBeenCalledWith("@mychannel");
expect(sendPollMock).toHaveBeenCalledWith("-100321", "Which one?", ["A", "B"], {
allows_multiple_answers: false,
is_anonymous: true,
});
});
});
});
});

View File

@@ -130,6 +130,7 @@ type ChannelHandlerParams = {
forceDocument?: boolean;
silent?: boolean;
mediaLocalRoots?: readonly string[];
gatewayClientScopes?: readonly string[];
};
// Channel docking: outbound delivery delegates to plugin.outbound adapters.
@@ -250,6 +251,7 @@ function createChannelOutboundContextBase(
deps: params.deps,
silent: params.silent,
mediaLocalRoots: params.mediaLocalRoots,
gatewayClientScopes: params.gatewayClientScopes,
};
}
@@ -275,6 +277,7 @@ type DeliverOutboundPayloadsCoreParams = {
session?: OutboundSessionContext;
mirror?: DeliveryMirror;
silent?: boolean;
gatewayClientScopes?: readonly string[];
};
function collectPayloadMediaSources(payloads: ReplyPayload[]): string[] {
@@ -508,6 +511,7 @@ export async function deliverOutboundPayloads(
forceDocument: params.forceDocument,
silent: params.silent,
mirror: params.mirror,
gatewayClientScopes: params.gatewayClientScopes,
}).catch(() => null); // Best-effort — don't block delivery if queue write fails.
// Wrap onError to detect partial failures under bestEffort mode.
@@ -576,6 +580,7 @@ async function deliverOutboundPayloadsCore(
forceDocument: params.forceDocument,
silent: params.silent,
mediaLocalRoots,
gatewayClientScopes: params.gatewayClientScopes,
});
const configuredTextLimit = handler.chunker
? resolveTextChunkLimit(cfg, channel, accountId, {

View File

@@ -75,6 +75,7 @@ function buildRecoveryDeliverParams(entry: QueuedDelivery, cfg: OpenClawConfig)
forceDocument: entry.forceDocument,
silent: entry.silent,
mirror: entry.mirror,
gatewayClientScopes: entry.gatewayClientScopes,
skipQueue: true, // Prevent re-enqueueing during recovery.
} satisfies Parameters<DeliverFn>[0];
}

View File

@@ -26,6 +26,8 @@ export type QueuedDeliveryPayload = {
forceDocument?: boolean;
silent?: boolean;
mirror?: OutboundMirror;
/** Gateway caller scopes at enqueue time, preserved for recovery replay. */
gatewayClientScopes?: readonly string[];
};
export interface QueuedDelivery extends QueuedDeliveryPayload {
@@ -142,6 +144,7 @@ export async function enqueueDelivery(
forceDocument: params.forceDocument,
silent: params.silent,
mirror: params.mirror,
gatewayClientScopes: params.gatewayClientScopes,
retryCount: 0,
});
return id;

View File

@@ -125,6 +125,7 @@ describe("delivery-queue recovery", () => {
bestEffort: true,
gifPlayback: true,
silent: true,
gatewayClientScopes: ["operator.write"],
mirror: {
sessionKey: "agent:main:main",
text: "a",
@@ -142,6 +143,7 @@ describe("delivery-queue recovery", () => {
bestEffort: true,
gifPlayback: true,
silent: true,
gatewayClientScopes: ["operator.write"],
mirror: {
sessionKey: "agent:main:main",
text: "a",

View File

@@ -23,6 +23,7 @@ describe("delivery-queue storage", () => {
bestEffort: true,
gifPlayback: true,
silent: true,
gatewayClientScopes: ["operator.write"],
mirror: {
sessionKey: "agent:main:main",
text: "hello",
@@ -45,6 +46,7 @@ describe("delivery-queue storage", () => {
bestEffort: true,
gifPlayback: true,
silent: true,
gatewayClientScopes: ["operator.write"],
mirror: {
sessionKey: "agent:main:main",
text: "hello",
@@ -157,6 +159,21 @@ describe("delivery-queue storage", () => {
expect(await loadPendingDeliveries(tmpDir())).toHaveLength(2);
});
it("persists gateway caller scopes for replay", async () => {
const id = await enqueueDelivery(
{
channel: "telegram",
to: "2",
payloads: [{ text: "b" }],
gatewayClientScopes: ["operator.write"],
},
tmpDir(),
);
const entry = readQueuedEntry(tmpDir(), id);
expect(entry.gatewayClientScopes).toEqual(["operator.write"]);
});
it("backfills lastAttemptAt for legacy retry entries during load", async () => {
const id = await enqueueDelivery(
{ channel: "whatsapp", to: "+1", payloads: [{ text: "legacy" }] },