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 <noreply@anthropic.com>
This commit is contained in:
Robin Waslander 2026-02-22 00:54:20 +01:00 committed by Ayaan Zaidi
parent 6d11b46994
commit cf5a03865e
5 changed files with 174 additions and 1 deletions

View File

@ -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<typeof vi.fn>;
readAllowFromStoreMock: ReturnType<typeof vi.fn>;
@ -43,6 +51,8 @@ type SlashHarnessMocks = {
finalizeInboundContextMock: ReturnType<typeof vi.fn>;
resolveConversationLabelMock: ReturnType<typeof vi.fn>;
createReplyPrefixOptionsMock: ReturnType<typeof vi.fn>;
recordSessionMetaFromInboundMock: ReturnType<typeof vi.fn>;
resolveStorePathMock: ReturnType<typeof vi.fn>;
};
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");
}

View File

@ -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();
});
});

View File

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

View File

@ -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<typeof import("../auto-reply/skill-commands.js")>();
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<typeof registerTelegramNativeCommands>[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<string, (ctx: unknown) => Promise<void>>();
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<void>) => {
commandHandlers.set(name, cb);
}),
} as unknown as Parameters<typeof registerTelegramNativeCommands>[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();
});
});

View File

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