From cf5a03865e1a667fe9a8a6f2082baafbafff16e0 Mon Sep 17 00:00:00 2001 From: Robin Waslander Date: Sun, 22 Feb 2026 00:54:20 +0100 Subject: [PATCH] fix(slash): persist channel metadata from slash command sessions Slash command handlers called finalizeInboundContext but never called recordSessionMetaFromInbound. Regular message paths do both. This meant session.meta.originatingChannel was never written for slash-command sessions, silently breaking approval gates, exec policy checks, and elevated mode. Fix: resolve storePath and call recordSessionMetaFromInbound after finalizeInboundContext in bot-native-commands.ts (Telegram) and slash.ts (Slack), mirroring the pattern in bot-message-context.ts. Closes #22985 Co-Authored-By: Claude Sonnet 4.6 --- src/slack/monitor/slash.test-harness.ts | 12 ++ src/slack/monitor/slash.test.ts | 18 +++ src/slack/monitor/slash.ts | 18 ++- .../bot-native-commands.session-meta.test.ts | 115 ++++++++++++++++++ src/telegram/bot-native-commands.ts | 12 ++ 5 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 src/telegram/bot-native-commands.session-meta.test.ts diff --git a/src/slack/monitor/slash.test-harness.ts b/src/slack/monitor/slash.test-harness.ts index 9935b347897..39dec929b44 100644 --- a/src/slack/monitor/slash.test-harness.ts +++ b/src/slack/monitor/slash.test-harness.ts @@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({ finalizeInboundContextMock: vi.fn(), resolveConversationLabelMock: vi.fn(), createReplyPrefixOptionsMock: vi.fn(), + recordSessionMetaFromInboundMock: vi.fn(), + resolveStorePathMock: vi.fn(), })); vi.mock("../../auto-reply/reply/provider-dispatcher.js", () => ({ @@ -35,6 +37,12 @@ vi.mock("../../channels/reply-prefix.js", () => ({ createReplyPrefixOptions: (...args: unknown[]) => mocks.createReplyPrefixOptionsMock(...args), })); +vi.mock("../../config/sessions.js", () => ({ + recordSessionMetaFromInbound: (...args: unknown[]) => + mocks.recordSessionMetaFromInboundMock(...args), + resolveStorePath: (...args: unknown[]) => mocks.resolveStorePathMock(...args), +})); + type SlashHarnessMocks = { dispatchMock: ReturnType; readAllowFromStoreMock: ReturnType; @@ -43,6 +51,8 @@ type SlashHarnessMocks = { finalizeInboundContextMock: ReturnType; resolveConversationLabelMock: ReturnType; createReplyPrefixOptionsMock: ReturnType; + recordSessionMetaFromInboundMock: ReturnType; + resolveStorePathMock: ReturnType; }; export function getSlackSlashMocks(): SlashHarnessMocks { @@ -61,4 +71,6 @@ export function resetSlackSlashMocks() { mocks.finalizeInboundContextMock.mockReset().mockImplementation((ctx: unknown) => ctx); mocks.resolveConversationLabelMock.mockReset().mockReturnValue(undefined); mocks.createReplyPrefixOptionsMock.mockReset().mockReturnValue({ onModelSelected: () => {} }); + mocks.recordSessionMetaFromInboundMock.mockReset().mockResolvedValue(undefined); + mocks.resolveStorePathMock.mockReset().mockReturnValue("/tmp/openclaw-sessions.json"); } diff --git a/src/slack/monitor/slash.test.ts b/src/slack/monitor/slash.test.ts index 53fa613b94d..eb647e1ade5 100644 --- a/src/slack/monitor/slash.test.ts +++ b/src/slack/monitor/slash.test.ts @@ -859,3 +859,21 @@ describe("slack slash commands access groups", () => { expectUnauthorizedResponse(respond); }); }); + +describe("slack slash command session metadata", () => { + const { recordSessionMetaFromInboundMock } = getSlackSlashMocks(); + + it("calls recordSessionMetaFromInbound after dispatching a slash command", async () => { + const harness = createPolicyHarness({ groupPolicy: "open" }); + await registerAndRunPolicySlash({ harness }); + + expect(dispatchMock).toHaveBeenCalledTimes(1); + expect(recordSessionMetaFromInboundMock).toHaveBeenCalledTimes(1); + const call = recordSessionMetaFromInboundMock.mock.calls[0]?.[0] as { + sessionKey?: string; + ctx?: { OriginatingChannel?: string }; + }; + expect(call.ctx?.OriginatingChannel).toBe("slack"); + expect(call.sessionKey).toBeDefined(); + }); +}); diff --git a/src/slack/monitor/slash.ts b/src/slack/monitor/slash.ts index 27af729dbf0..191a6b0c2c4 100644 --- a/src/slack/monitor/slash.ts +++ b/src/slack/monitor/slash.ts @@ -539,9 +539,14 @@ export async function registerSlackMonitorSlashCommands(params: { import("../../auto-reply/reply/inbound-context.js"), import("../../auto-reply/reply/provider-dispatcher.js"), ]); - const [{ resolveConversationLabel }, { createReplyPrefixOptions }] = await Promise.all([ + const [ + { resolveConversationLabel }, + { createReplyPrefixOptions }, + { recordSessionMetaFromInbound, resolveStorePath }, + ] = await Promise.all([ import("../../channels/conversation-label.js"), import("../../channels/reply-prefix.js"), + import("../../config/sessions.js"), ]); const route = resolveAgentRoute({ @@ -605,6 +610,17 @@ export async function registerSlackMonitorSlashCommands(params: { OriginatingTo: `user:${command.user_id}`, }); + const storePath = resolveStorePath(cfg.session?.store, { + agentId: route.agentId, + }); + void recordSessionMetaFromInbound({ + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + ctx: ctxPayload, + }).catch((err) => { + runtime.error?.(danger(`slack slash: failed updating session meta: ${String(err)}`)); + }); + const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ cfg, agentId: route.agentId, diff --git a/src/telegram/bot-native-commands.session-meta.test.ts b/src/telegram/bot-native-commands.session-meta.test.ts new file mode 100644 index 00000000000..05939f304be --- /dev/null +++ b/src/telegram/bot-native-commands.session-meta.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import type { TelegramAccountConfig } from "../config/types.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { registerTelegramNativeCommands } from "./bot-native-commands.js"; + +// All mocks scoped to this file only — does not affect bot-native-commands.test.ts + +const sessionMocks = vi.hoisted(() => ({ + recordSessionMetaFromInbound: vi.fn(), + resolveStorePath: vi.fn(), +})); + +vi.mock("../config/sessions.js", () => ({ + recordSessionMetaFromInbound: sessionMocks.recordSessionMetaFromInbound, + resolveStorePath: sessionMocks.resolveStorePath, +})); +vi.mock("../pairing/pairing-store.js", () => ({ + readChannelAllowFromStore: vi.fn(async () => []), +})); +vi.mock("../auto-reply/reply/inbound-context.js", () => ({ + finalizeInboundContext: vi.fn((ctx: unknown) => ctx), +})); +vi.mock("../auto-reply/reply/provider-dispatcher.js", () => ({ + dispatchReplyWithBufferedBlockDispatcher: vi.fn(async () => undefined), +})); +vi.mock("../channels/reply-prefix.js", () => ({ + createReplyPrefixOptions: vi.fn(() => ({ onModelSelected: () => {} })), +})); +vi.mock("../auto-reply/skill-commands.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, listSkillCommandsForAgents: vi.fn(() => []) }; +}); +vi.mock("../plugins/commands.js", () => ({ + getPluginCommandSpecs: vi.fn(() => []), + matchPluginCommand: vi.fn(() => null), + executePluginCommand: vi.fn(async () => ({ text: "ok" })), +})); +vi.mock("./bot/delivery.js", () => ({ + deliverReplies: vi.fn(async () => ({ delivered: true })), +})); + +const buildParams = (cfg: OpenClawConfig, accountId = "default") => ({ + bot: { + api: { + setMyCommands: vi.fn().mockResolvedValue(undefined), + sendMessage: vi.fn().mockResolvedValue(undefined), + }, + command: vi.fn(), + } as unknown as Parameters[0]["bot"], + cfg, + runtime: {} as unknown as RuntimeEnv, + accountId, + telegramCfg: {} as TelegramAccountConfig, + allowFrom: [], + groupAllowFrom: [], + replyToMode: "off" as const, + textLimit: 4096, + useAccessGroups: false, + nativeEnabled: true, + nativeSkillsEnabled: true, + nativeDisabledExplicit: false, + resolveGroupPolicy: () => ({ allowlistEnabled: false, allowed: true }), + resolveTelegramGroupConfig: () => ({ + groupConfig: undefined, + topicConfig: undefined, + }), + shouldSkipUpdate: () => false, + opts: { token: "token" }, +}); + +describe("registerTelegramNativeCommands — session metadata", () => { + it("calls recordSessionMetaFromInbound after a native slash command", async () => { + sessionMocks.recordSessionMetaFromInbound.mockReset().mockResolvedValue(undefined); + sessionMocks.resolveStorePath.mockReset().mockReturnValue("/tmp/openclaw-sessions.json"); + + const commandHandlers = new Map Promise>(); + const cfg: OpenClawConfig = {}; + + registerTelegramNativeCommands({ + ...buildParams(cfg), + allowFrom: ["*"], + bot: { + api: { + setMyCommands: vi.fn().mockResolvedValue(undefined), + sendMessage: vi.fn().mockResolvedValue(undefined), + }, + command: vi.fn((name: string, cb: (ctx: unknown) => Promise) => { + commandHandlers.set(name, cb); + }), + } as unknown as Parameters[0]["bot"], + }); + + const handler = commandHandlers.get("status"); + expect(handler).toBeTruthy(); + await handler?.({ + match: "", + message: { + message_id: 1, + date: Math.floor(Date.now() / 1000), + chat: { id: 100, type: "private" }, + from: { id: 200, username: "bob" }, + }, + }); + + expect(sessionMocks.recordSessionMetaFromInbound).toHaveBeenCalledTimes(1); + const call = ( + sessionMocks.recordSessionMetaFromInbound.mock.calls as unknown as Array< + [{ sessionKey?: string; ctx?: { OriginatingChannel?: string } }] + > + )[0]?.[0]; + expect(call?.ctx?.OriginatingChannel).toBe("telegram"); + expect(call?.sessionKey).toBeDefined(); + }); +}); diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 424139c84d7..ec73c3f0af7 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -17,6 +17,7 @@ import { createReplyPrefixOptions } from "../channels/reply-prefix.js"; import type { OpenClawConfig } from "../config/config.js"; import type { ChannelGroupPolicy } from "../config/group-policy.js"; import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; +import { recordSessionMetaFromInbound, resolveStorePath } from "../config/sessions.js"; import { normalizeTelegramCommandName, resolveTelegramCustomCommands, @@ -594,6 +595,17 @@ export const registerTelegramNativeCommands = ({ OriginatingTo: `telegram:${chatId}`, }); + const storePath = resolveStorePath(cfg.session?.store, { + agentId: route.agentId, + }); + void recordSessionMetaFromInbound({ + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + ctx: ctxPayload, + }).catch((err) => { + runtime.error?.(danger(`telegram slash: failed updating session meta: ${String(err)}`)); + }); + const disableBlockStreaming = typeof telegramCfg.blockStreaming === "boolean" ? !telegramCfg.blockStreaming