From 24960568862882cafed97c23d055028e2e0a8dba Mon Sep 17 00:00:00 2001 From: hlbbbbbbb Date: Wed, 28 Jan 2026 09:24:40 +0800 Subject: [PATCH 01/58] fix(minimax): use correct API endpoint and format MiniMax has updated their API. The previous configuration used an incorrect endpoint (api.minimax.io/anthropic) with anthropic-messages format, which no longer works. Changes: - Update MINIMAX_API_BASE_URL to https://api.minimax.chat/v1 - Change API format from anthropic-messages to openai-completions - Remove minimax from isAnthropicApi check in transcript-policy This fixes the issue where MiniMax API calls return no results. --- src/agents/models-config.providers.ts | 4 ++-- src/agents/transcript-policy.ts | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 76f1c3acd..cb556aced 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -17,7 +17,7 @@ import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js"; type ModelsConfig = NonNullable; export type ProviderConfig = NonNullable[string]; -const MINIMAX_API_BASE_URL = "https://api.minimax.io/anthropic"; +const MINIMAX_API_BASE_URL = "https://api.minimax.chat/v1"; const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M2.1"; const MINIMAX_DEFAULT_VISION_MODEL_ID = "MiniMax-VL-01"; const MINIMAX_DEFAULT_CONTEXT_WINDOW = 200000; @@ -244,7 +244,7 @@ export function normalizeProviders(params: { function buildMinimaxProvider(): ProviderConfig { return { baseUrl: MINIMAX_API_BASE_URL, - api: "anthropic-messages", + api: "openai-completions", models: [ { id: MINIMAX_DEFAULT_MODEL_ID, diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index 3ea06ce88..9ae14d38f 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -51,7 +51,8 @@ function isOpenAiProvider(provider?: string | null): boolean { function isAnthropicApi(modelApi?: string | null, provider?: string | null): boolean { if (modelApi === "anthropic-messages") return true; const normalized = normalizeProviderId(provider ?? ""); - return normalized === "anthropic" || normalized === "minimax"; + // MiniMax now uses openai-completions API, not anthropic-messages + return normalized === "anthropic"; } function isMistralModel(params: { provider?: string | null; modelId?: string | null }): boolean { From eb50314d7d30450775249e15a874dafd681428eb Mon Sep 17 00:00:00 2001 From: Shadow Date: Tue, 27 Jan 2026 19:47:21 -0600 Subject: [PATCH 02/58] fix: update MiniMax provider config (#3064) (thanks @hlbbbbbbb) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7ec0a747..37c3c17d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,7 @@ Status: beta. - Web UI: auto-expand the chat compose textarea while typing (with sensible max height). (#2950) Thanks @shivamraut101. - Gateway: prevent crashes on transient network errors (fetch failures, timeouts, DNS). Added fatal error detection to only exit on truly critical errors. Fixes #2895, #2879, #2873. (#2980) Thanks @elliotsecops. - Agents: guard channel tool listActions to avoid plugin crashes. (#2859) Thanks @mbelinky. +- Providers: update MiniMax API endpoint and compatibility mode. (#3064) Thanks @hlbbbbbbb. - Gateway: suppress AbortError and transient network errors in unhandled rejections. (#2451) Thanks @Glucksberg. - TTS: keep /tts status replies on text-only commands and avoid duplicate block-stream audio. (#2451) Thanks @Glucksberg. - Security: pin npm overrides to keep tar@7.5.4 for install toolchains. From 558b64f5fa07f35fefdd893776ab9202c66fec51 Mon Sep 17 00:00:00 2001 From: ryan <39743613+ryancontent@users.noreply.github.com> Date: Wed, 28 Jan 2026 11:33:51 +1300 Subject: [PATCH 03/58] fix: handle Telegram network errors gracefully to prevent gateway crashes - Expand recoverable error codes (ECONNABORTED, ERR_NETWORK) - Add message patterns for 'typeerror: fetch failed' and 'undici' errors - Add isNetworkRelatedError() helper for broad network failure detection - Retry on all network-related errors instead of crashing gateway - Remove unnecessary 'void' from fire-and-forget patterns - Add tests for new error patterns Fixes #3005 --- src/telegram/bot-native-commands.ts | 4 ++-- src/telegram/monitor.ts | 20 +++++++++++++++++++- src/telegram/network-errors.test.ts | 12 ++++++++++++ src/telegram/network-errors.ts | 4 ++++ 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 4cca71d14..0dd372c3e 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -322,7 +322,7 @@ export const registerTelegramNativeCommands = ({ ]; if (allCommands.length > 0) { - void withTelegramApiErrorLogging({ + withTelegramApiErrorLogging({ operation: "setMyCommands", runtime, fn: () => bot.api.setMyCommands(allCommands), @@ -576,7 +576,7 @@ export const registerTelegramNativeCommands = ({ } } } else if (nativeDisabledExplicit) { - void withTelegramApiErrorLogging({ + withTelegramApiErrorLogging({ operation: "setMyCommands", runtime, fn: () => bot.api.setMyCommands([]), diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts index 59df7098d..c3b3a5a2f 100644 --- a/src/telegram/monitor.ts +++ b/src/telegram/monitor.ts @@ -74,6 +74,23 @@ const isGetUpdatesConflict = (err: unknown) => { return haystack.includes("getupdates"); }; +const NETWORK_ERROR_SNIPPETS = [ + "fetch failed", + "network", + "timeout", + "socket", + "econnreset", + "econnrefused", + "undici", +]; + +const isNetworkRelatedError = (err: unknown) => { + if (!err) return false; + const message = formatErrorMessage(err).toLowerCase(); + if (!message) return false; + return NETWORK_ERROR_SNIPPETS.some((snippet) => message.includes(snippet)); +}; + export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { const cfg = opts.config ?? loadConfig(); const account = resolveTelegramAccount({ @@ -158,7 +175,8 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { } const isConflict = isGetUpdatesConflict(err); const isRecoverable = isRecoverableTelegramNetworkError(err, { context: "polling" }); - if (!isConflict && !isRecoverable) { + const isNetworkError = isNetworkRelatedError(err); + if (!isConflict && !isRecoverable && !isNetworkError) { throw err; } restartAttempts += 1; diff --git a/src/telegram/network-errors.test.ts b/src/telegram/network-errors.test.ts index ae42cbb97..db582355f 100644 --- a/src/telegram/network-errors.test.ts +++ b/src/telegram/network-errors.test.ts @@ -8,6 +8,13 @@ describe("isRecoverableTelegramNetworkError", () => { expect(isRecoverableTelegramNetworkError(err)).toBe(true); }); + it("detects additional recoverable error codes", () => { + const aborted = Object.assign(new Error("aborted"), { code: "ECONNABORTED" }); + const network = Object.assign(new Error("network"), { code: "ERR_NETWORK" }); + expect(isRecoverableTelegramNetworkError(aborted)).toBe(true); + expect(isRecoverableTelegramNetworkError(network)).toBe(true); + }); + it("detects AbortError names", () => { const err = Object.assign(new Error("The operation was aborted"), { name: "AbortError" }); expect(isRecoverableTelegramNetworkError(err)).toBe(true); @@ -19,6 +26,11 @@ describe("isRecoverableTelegramNetworkError", () => { expect(isRecoverableTelegramNetworkError(err)).toBe(true); }); + it("detects expanded message patterns", () => { + expect(isRecoverableTelegramNetworkError(new Error("TypeError: fetch failed"))).toBe(true); + expect(isRecoverableTelegramNetworkError(new Error("Undici: socket failure"))).toBe(true); + }); + it("skips message matches for send context", () => { const err = new TypeError("fetch failed"); expect(isRecoverableTelegramNetworkError(err, { context: "send" })).toBe(false); diff --git a/src/telegram/network-errors.ts b/src/telegram/network-errors.ts index 70cd81994..bb3432432 100644 --- a/src/telegram/network-errors.ts +++ b/src/telegram/network-errors.ts @@ -15,6 +15,8 @@ const RECOVERABLE_ERROR_CODES = new Set([ "UND_ERR_BODY_TIMEOUT", "UND_ERR_SOCKET", "UND_ERR_ABORTED", + "ECONNABORTED", + "ERR_NETWORK", ]); const RECOVERABLE_ERROR_NAMES = new Set([ @@ -27,6 +29,8 @@ const RECOVERABLE_ERROR_NAMES = new Set([ const RECOVERABLE_MESSAGE_SNIPPETS = [ "fetch failed", + "typeerror: fetch failed", + "undici", "network error", "network request", "client network socket disconnected", From 57d9c09f6efa567fc1822ba6e5075fc32686b325 Mon Sep 17 00:00:00 2001 From: Shadow Date: Tue, 27 Jan 2026 19:55:39 -0600 Subject: [PATCH 04/58] fix: expand Telegram polling network recovery (#3013) (thanks @ryancontent) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37c3c17d3..9e503882d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,7 @@ Status: beta. - Gateway: prevent crashes on transient network errors (fetch failures, timeouts, DNS). Added fatal error detection to only exit on truly critical errors. Fixes #2895, #2879, #2873. (#2980) Thanks @elliotsecops. - Agents: guard channel tool listActions to avoid plugin crashes. (#2859) Thanks @mbelinky. - Providers: update MiniMax API endpoint and compatibility mode. (#3064) Thanks @hlbbbbbbb. +- Telegram: treat more network errors as recoverable in polling. (#3013) Thanks @ryancontent. - Gateway: suppress AbortError and transient network errors in unhandled rejections. (#2451) Thanks @Glucksberg. - TTS: keep /tts status replies on text-only commands and avoid duplicate block-stream audio. (#2451) Thanks @Glucksberg. - Security: pin npm overrides to keep tar@7.5.4 for install toolchains. From 7958ead91a724c18fda084776173504492ad813b Mon Sep 17 00:00:00 2001 From: "nonggia.liang" Date: Tue, 27 Jan 2026 15:55:53 +0800 Subject: [PATCH 05/58] fix: resolve Discord usernames to user IDs for outbound messages When sending Discord messages via cron jobs or the message tool, usernames like "john.doe" were incorrectly treated as channel names, causing silent delivery failures. This fix adds a resolveDiscordTarget() function that: - Queries Discord directory to resolve usernames to user IDs - Falls back to standard parsing for known formats - Enables sending DMs by username without requiring explicit user:ID format Changes: - Added resolveDiscordTarget() in targets.ts with directory lookup - Added parseAndResolveRecipient() in send.shared.ts - Updated all outbound send functions to use username resolution Fixes #2627 --- src/discord/send.outbound.ts | 8 ++--- src/discord/send.shared.ts | 40 ++++++++++++++++++++++- src/discord/targets.ts | 62 ++++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 5 deletions(-) diff --git a/src/discord/send.outbound.ts b/src/discord/send.outbound.ts index a47d0f4f1..22b402ae3 100644 --- a/src/discord/send.outbound.ts +++ b/src/discord/send.outbound.ts @@ -13,7 +13,7 @@ import { createDiscordClient, normalizeDiscordPollInput, normalizeStickerIds, - parseRecipient, + parseAndResolveRecipient, resolveChannelId, sendDiscordMedia, sendDiscordText, @@ -49,7 +49,7 @@ export async function sendMessageDiscord( const chunkMode = resolveChunkMode(cfg, "discord", accountInfo.accountId); const textWithTables = convertMarkdownTables(text ?? "", tableMode); const { token, rest, request } = createDiscordClient(opts, cfg); - const recipient = parseRecipient(to); + const recipient = await parseAndResolveRecipient(to, opts.accountId); const { channelId } = await resolveChannelId(rest, recipient, request); let result: { id: string; channel_id: string } | { id: string | null; channel_id: string }; try { @@ -104,7 +104,7 @@ export async function sendStickerDiscord( ): Promise { const cfg = loadConfig(); const { rest, request } = createDiscordClient(opts, cfg); - const recipient = parseRecipient(to); + const recipient = await parseAndResolveRecipient(to, opts.accountId); const { channelId } = await resolveChannelId(rest, recipient, request); const content = opts.content?.trim(); const stickers = normalizeStickerIds(stickerIds); @@ -131,7 +131,7 @@ export async function sendPollDiscord( ): Promise { const cfg = loadConfig(); const { rest, request } = createDiscordClient(opts, cfg); - const recipient = parseRecipient(to); + const recipient = await parseAndResolveRecipient(to, opts.accountId); const { channelId } = await resolveChannelId(rest, recipient, request); const content = opts.content?.trim(); const payload = normalizeDiscordPollInput(poll); diff --git a/src/discord/send.shared.ts b/src/discord/send.shared.ts index 4919be29d..1cf2a93a9 100644 --- a/src/discord/send.shared.ts +++ b/src/discord/send.shared.ts @@ -13,7 +13,7 @@ import type { ChunkMode } from "../auto-reply/chunk.js"; import { chunkDiscordTextWithMode } from "./chunk.js"; import { fetchChannelPermissionsDiscord, isThreadChannelType } from "./send.permissions.js"; import { DiscordSendError } from "./send.types.js"; -import { parseDiscordTarget } from "./targets.js"; +import { parseDiscordTarget, resolveDiscordTarget } from "./targets.js"; import { normalizeDiscordToken } from "./token.js"; const DISCORD_TEXT_LIMIT = 2000; @@ -101,6 +101,44 @@ function parseRecipient(raw: string): DiscordRecipient { return { kind: target.kind, id: target.id }; } +/** + * Parse and resolve Discord recipient, including username lookup. + * This enables sending DMs by username (e.g., "john.doe") by querying + * the Discord directory to resolve usernames to user IDs. + * + * @param raw - The recipient string (username, ID, or known format) + * @param accountId - Discord account ID to use for directory lookup + * @returns Parsed DiscordRecipient with resolved user ID if applicable + */ +export async function parseAndResolveRecipient( + raw: string, + accountId?: string, +): Promise { + const cfg = loadConfig(); + const accountInfo = resolveDiscordAccount({ cfg, accountId }); + + // First try to resolve using directory lookup (handles usernames) + const resolved = await resolveDiscordTarget(raw, { + cfg, + accountId: accountInfo.accountId, + }); + + if (resolved) { + return { kind: resolved.kind, id: resolved.id }; + } + + // Fallback to standard parsing (for channels, etc.) + const parsed = parseDiscordTarget(raw, { + ambiguousMessage: `Ambiguous Discord recipient "${raw.trim()}". Use "user:${raw.trim()}" for DMs or "channel:${raw.trim()}" for channel messages.`, + }); + + if (!parsed) { + throw new Error("Recipient is required for Discord sends"); + } + + return { kind: parsed.kind, id: parsed.id }; +} + function normalizeStickerIds(raw: string[]) { const ids = raw.map((entry) => entry.trim()).filter(Boolean); if (ids.length === 0) { diff --git a/src/discord/targets.ts b/src/discord/targets.ts index 3a3c93ec8..311955182 100644 --- a/src/discord/targets.ts +++ b/src/discord/targets.ts @@ -5,8 +5,13 @@ import { type MessagingTarget, type MessagingTargetKind, type MessagingTargetParseOptions, + type DirectoryConfigParams, + type ChannelDirectoryEntry, } from "../channels/targets.js"; +import { listDiscordDirectoryPeersLive } from "./directory-live.js"; +import { resolveDiscordAccount } from "./accounts.js"; + export type DiscordTargetKind = MessagingTargetKind; export type DiscordTarget = MessagingTarget; @@ -60,3 +65,60 @@ export function resolveDiscordChannelId(raw: string): string { const target = parseDiscordTarget(raw, { defaultKind: "channel" }); return requireTargetKind({ platform: "Discord", target, kind: "channel" }); } + +/** + * Resolve a Discord username to user ID using the directory lookup. + * This enables sending DMs by username instead of requiring explicit user IDs. + * + * @param raw - The username or raw target string (e.g., "john.doe") + * @param options - Directory configuration params (cfg, accountId, limit) + * @returns Parsed MessagingTarget with user ID, or undefined if not found + */ +export async function resolveDiscordTarget( + raw: string, + options: DirectoryConfigParams, +): Promise { + const trimmed = raw.trim(); + if (!trimmed) return undefined; + + // If already a known format, parse directly + const directParse = parseDiscordTarget(trimmed, options); + if (directParse && directParse.kind !== "channel" && !isLikelyUsername(trimmed)) { + return directParse; + } + + // Try to resolve as a username via directory lookup + try { + const directoryEntries = await listDiscordDirectoryPeersLive({ + ...options, + query: trimmed, + limit: 1, + }); + + const match = directoryEntries[0]; + if (match && match.kind === "user") { + // Extract user ID from the directory entry (format: "user:") + const userId = match.id.replace(/^user:/, ""); + return buildMessagingTarget("user", userId, trimmed); + } + } catch (error) { + // Directory lookup failed - fall through to parse as-is + // This preserves existing behavior for channel names + } + + // Fallback to original parsing (for channels, etc.) + return parseDiscordTarget(trimmed, options); +} + +/** + * Check if a string looks like a Discord username (not a mention, prefix, or ID). + * Usernames typically don't start with special characters except underscore. + */ +function isLikelyUsername(input: string): boolean { + // Skip if it's already a known format + if (/^(user:|channel:|discord:|@|<@!?)|[\d]+$/.test(input)) { + return false; + } + // Likely a username if it doesn't match known patterns + return true; +} From cf827f03e8999189d34101f03a87414e4902da70 Mon Sep 17 00:00:00 2001 From: Shadow Date: Tue, 27 Jan 2026 20:31:51 -0600 Subject: [PATCH 06/58] tests: cover Discord username resolution --- src/discord/targets.test.ts | 42 +++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/src/discord/targets.test.ts b/src/discord/targets.test.ts index 3eee1eb1e..7ac39450b 100644 --- a/src/discord/targets.test.ts +++ b/src/discord/targets.test.ts @@ -1,7 +1,13 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ClawdbotConfig } from "../config/config.js"; import { normalizeDiscordMessagingTarget } from "../channels/plugins/normalize/discord.js"; -import { parseDiscordTarget, resolveDiscordChannelId } from "./targets.js"; +import { listDiscordDirectoryPeersLive } from "./directory-live.js"; +import { parseDiscordTarget, resolveDiscordChannelId, resolveDiscordTarget } from "./targets.js"; + +vi.mock("./directory-live.js", () => ({ + listDiscordDirectoryPeersLive: vi.fn(), +})); describe("parseDiscordTarget", () => { it("parses user mention and prefixes", () => { @@ -68,6 +74,38 @@ describe("resolveDiscordChannelId", () => { }); }); +describe("resolveDiscordTarget", () => { + const cfg = { channels: { discord: {} } } as ClawdbotConfig; + const listPeers = vi.mocked(listDiscordDirectoryPeersLive); + + beforeEach(() => { + listPeers.mockReset(); + }); + + it("returns a resolved user for usernames", async () => { + listPeers.mockResolvedValueOnce([{ kind: "user", id: "user:999", name: "Jane" } as const]); + + await expect( + resolveDiscordTarget("jane", { cfg, accountId: "default" }), + ).resolves.toMatchObject({ kind: "user", id: "999", normalized: "user:999" }); + }); + + it("falls back to parsing when lookup misses", async () => { + listPeers.mockResolvedValueOnce([]); + await expect( + resolveDiscordTarget("general", { cfg, accountId: "default" }), + ).resolves.toMatchObject({ kind: "channel", id: "general" }); + }); + + it("does not call directory lookup for explicit user ids", async () => { + listPeers.mockResolvedValueOnce([]); + await expect( + resolveDiscordTarget("user:123", { cfg, accountId: "default" }), + ).resolves.toMatchObject({ kind: "user", id: "123" }); + expect(listPeers).not.toHaveBeenCalled(); + }); +}); + describe("normalizeDiscordMessagingTarget", () => { it("defaults raw numeric ids to channels", () => { expect(normalizeDiscordMessagingTarget("123")).toBe("channel:123"); From 7bfe6ab2d64e5fe635a775d8805847843e28b487 Mon Sep 17 00:00:00 2001 From: Shadow Date: Tue, 27 Jan 2026 21:04:38 -0600 Subject: [PATCH 07/58] fix: resolve Discord usernames for outbound sends (#2649) (thanks @nonggialiang) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e503882d..79bca9908 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,7 @@ Status: beta. - Agents: guard channel tool listActions to avoid plugin crashes. (#2859) Thanks @mbelinky. - Providers: update MiniMax API endpoint and compatibility mode. (#3064) Thanks @hlbbbbbbb. - Telegram: treat more network errors as recoverable in polling. (#3013) Thanks @ryancontent. +- Discord: resolve usernames to user IDs for outbound messages. (#2649) Thanks @nonggialiang. - Gateway: suppress AbortError and transient network errors in unhandled rejections. (#2451) Thanks @Glucksberg. - TTS: keep /tts status replies on text-only commands and avoid duplicate block-stream audio. (#2451) Thanks @Glucksberg. - Security: pin npm overrides to keep tar@7.5.4 for install toolchains. From 394308076aefadefb3a718a4cddce46004d8dffe Mon Sep 17 00:00:00 2001 From: Boran Cui Date: Tue, 27 Jan 2026 21:10:09 +0800 Subject: [PATCH 08/58] Update Moonshot Kimi model references from kimi-k2-0905-preview to the latest kimi-k2.5 --- docs/concepts/model-providers.md | 7 ++++--- docs/gateway/configuration.md | 10 +++++----- docs/providers/moonshot.md | 15 +++++++++++++-- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index ef27fc9e3..9dbb984fc 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -130,9 +130,10 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider: - Provider: `moonshot` - Auth: `MOONSHOT_API_KEY` -- Example model: `moonshot/kimi-k2-0905-preview` +- Example model: `moonshot/kimi-k2.5` - Kimi K2 model IDs: {/* moonshot-kimi-k2-model-refs:start */} + - `moonshot/kimi-k2.5` - `moonshot/kimi-k2-0905-preview` - `moonshot/kimi-k2-turbo-preview` - `moonshot/kimi-k2-thinking` @@ -141,7 +142,7 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider: ```json5 { agents: { - defaults: { model: { primary: "moonshot/kimi-k2-0905-preview" } } + defaults: { model: { primary: "moonshot/kimi-k2.5" } } }, models: { mode: "merge", @@ -150,7 +151,7 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider: baseUrl: "https://api.moonshot.ai/v1", apiKey: "${MOONSHOT_API_KEY}", api: "openai-completions", - models: [{ id: "kimi-k2-0905-preview", name: "Kimi K2 0905 Preview" }] + models: [{ id: "kimi-k2.5", name: "Kimi K2.5" }] } } } diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index f5438fb46..15261c809 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2396,8 +2396,8 @@ Use Moonshot's OpenAI-compatible endpoint: env: { MOONSHOT_API_KEY: "sk-..." }, agents: { defaults: { - model: { primary: "moonshot/kimi-k2-0905-preview" }, - models: { "moonshot/kimi-k2-0905-preview": { alias: "Kimi K2" } } + model: { primary: "moonshot/kimi-k2.5" }, + models: { "moonshot/kimi-k2.5": { alias: "Kimi K2.5" } } } }, models: { @@ -2409,8 +2409,8 @@ Use Moonshot's OpenAI-compatible endpoint: api: "openai-completions", models: [ { - id: "kimi-k2-0905-preview", - name: "Kimi K2 0905 Preview", + id: "kimi-k2.5", + name: "Kimi K2.5", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, @@ -2426,7 +2426,7 @@ Use Moonshot's OpenAI-compatible endpoint: Notes: - Set `MOONSHOT_API_KEY` in the environment or use `moltbot onboard --auth-choice moonshot-api-key`. -- Model ref: `moonshot/kimi-k2-0905-preview`. +- Model ref: `moonshot/kimi-k2.5`. - Use `https://api.moonshot.cn/v1` if you need the China endpoint. ### Kimi Code diff --git a/docs/providers/moonshot.md b/docs/providers/moonshot.md index 7e0723f7e..a1f2d18ad 100644 --- a/docs/providers/moonshot.md +++ b/docs/providers/moonshot.md @@ -9,11 +9,12 @@ read_when: # Moonshot AI (Kimi) Moonshot provides the Kimi API with OpenAI-compatible endpoints. Configure the -provider and set the default model to `moonshot/kimi-k2-0905-preview`, or use +provider and set the default model to `moonshot/kimi-k2.5`, or use Kimi Code with `kimi-code/kimi-for-coding`. Current Kimi K2 model IDs: {/* moonshot-kimi-k2-ids:start */} +- `kimi-k2.5` - `kimi-k2-0905-preview` - `kimi-k2-turbo-preview` - `kimi-k2-thinking` @@ -39,9 +40,10 @@ Note: Moonshot and Kimi Code are separate providers. Keys are not interchangeabl env: { MOONSHOT_API_KEY: "sk-..." }, agents: { defaults: { - model: { primary: "moonshot/kimi-k2-0905-preview" }, + model: { primary: "moonshot/kimi-k2.5" }, models: { // moonshot-kimi-k2-aliases:start + "moonshot/kimi-k2.5": { alias: "Kimi K2.5" }, "moonshot/kimi-k2-0905-preview": { alias: "Kimi K2" }, "moonshot/kimi-k2-turbo-preview": { alias: "Kimi K2 Turbo" }, "moonshot/kimi-k2-thinking": { alias: "Kimi K2 Thinking" }, @@ -59,6 +61,15 @@ Note: Moonshot and Kimi Code are separate providers. Keys are not interchangeabl api: "openai-completions", models: [ // moonshot-kimi-k2-models:start + { + id: "kimi-k2.5", + name: "Kimi K2.5", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 256000, + maxTokens: 8192 + }, { id: "kimi-k2-0905-preview", name: "Kimi K2 0905 Preview", From b8aa041dcc469d77a5016b28f4e6c8a485a1bc41 Mon Sep 17 00:00:00 2001 From: Boran Cui Date: Tue, 27 Jan 2026 21:15:57 +0800 Subject: [PATCH 09/58] Update Moonshot Kimi model references to kimi-k2.5 --- src/agents/models-config.providers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index cb556aced..a176dac8a 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -31,7 +31,7 @@ const MINIMAX_API_COST = { }; const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1"; -const MOONSHOT_DEFAULT_MODEL_ID = "kimi-k2-0905-preview"; +const MOONSHOT_DEFAULT_MODEL_ID = "kimi-k2.5"; const MOONSHOT_DEFAULT_CONTEXT_WINDOW = 256000; const MOONSHOT_DEFAULT_MAX_TOKENS = 8192; const MOONSHOT_DEFAULT_COST = { @@ -275,7 +275,7 @@ function buildMoonshotProvider(): ProviderConfig { models: [ { id: MOONSHOT_DEFAULT_MODEL_ID, - name: "Kimi K2 0905 Preview", + name: "Kimi K2.5", reasoning: false, input: ["text"], cost: MOONSHOT_DEFAULT_COST, From d0ef4d3b85bb2df98925169d50e38042fe28f161 Mon Sep 17 00:00:00 2001 From: Shadow Date: Tue, 27 Jan 2026 21:09:48 -0600 Subject: [PATCH 10/58] fix: update Moonshot Kimi model references (#2762) (thanks @MarvinCui) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79bca9908..6a68c3769 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,7 @@ Status: beta. - Providers: update MiniMax API endpoint and compatibility mode. (#3064) Thanks @hlbbbbbbb. - Telegram: treat more network errors as recoverable in polling. (#3013) Thanks @ryancontent. - Discord: resolve usernames to user IDs for outbound messages. (#2649) Thanks @nonggialiang. +- Providers: update Moonshot Kimi model references to kimi-k2.5. (#2762) Thanks @MarvinCui. - Gateway: suppress AbortError and transient network errors in unhandled rejections. (#2451) Thanks @Glucksberg. - TTS: keep /tts status replies on text-only commands and avoid duplicate block-stream audio. (#2451) Thanks @Glucksberg. - Security: pin npm overrides to keep tar@7.5.4 for install toolchains. From c5effb78f319ffde927474adc32e8caf3b8acc10 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 27 Jan 2026 22:29:09 -0500 Subject: [PATCH 11/58] Modify CLI banner ASCII art Updated the ASCII art for the CLI banner. --- src/cli/banner.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/cli/banner.ts b/src/cli/banner.ts index 0d9c435c8..6ca7d4cbc 100644 --- a/src/cli/banner.ts +++ b/src/cli/banner.ts @@ -65,12 +65,12 @@ export function formatCliBannerLine(version: string, options: BannerOptions = {} } const LOBSTER_ASCII = [ - "░████░█░░░░░█████░█░░░█░███░░████░░████░░▀█▀", - "█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░░█░█░░░█░░█░", - "█░░░░░█░░░░░█████░█░█░█░█░░█░████░░█░░░█░░█░", - "█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░█░░█░░░█░░█░", - "░████░█████░█░░░█░░█░█░░███░░████░░░███░░░█░", - " 🦞 FRESH DAILY 🦞", + "▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄", + "██░▄▀▄░██░▄▄▄░██░████▄▄░▄▄██░▄▄▀██░▄▄▄░█▄▄░▄▄██", + "██░█░█░██░███░██░██████░████░▄▄▀██░███░███░████", + "██░███░██░▀▀▀░██░▀▀░███░████░▀▀░██░▀▀▀░███░████", + "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀", + " 🦞 FRESH DAILY 🦞 ", ]; export function formatCliBannerArt(options: BannerOptions = {}): string { From 8f452dbc08d52c432f4d32ff7bceb2ecf1aac043 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 27 Jan 2026 22:30:38 -0500 Subject: [PATCH 12/58] Update wizard header with new ASCII art --- src/commands/onboard-helpers.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index 03fe77a27..165365bb6 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -64,12 +64,12 @@ export function randomToken(): string { export function printWizardHeader(runtime: RuntimeEnv) { const header = [ - "░████░█░░░░░█████░█░░░█░███░░████░░████░░▀█▀", - "█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░░█░█░░░█░░█░", - "█░░░░░█░░░░░█████░█░█░█░█░░█░████░░█░░░█░░█░", - "█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░█░░█░░░█░░█░", - "░████░█████░█░░░█░░█░█░░███░░████░░░███░░░█░", - " 🦞 FRESH DAILY 🦞", + "▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄", + "██░▄▀▄░██░▄▄▄░██░████▄▄░▄▄██░▄▄▀██░▄▄▄░█▄▄░▄▄██", + "██░█░█░██░███░██░██████░████░▄▄▀██░███░███░████", + "██░███░██░▀▀▀░██░▀▀░███░████░▀▀░██░▀▀▀░███░████", + "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀", + " 🦞 FRESH DAILY 🦞 ", ].join("\n"); runtime.log(header); } From 915497114e3a96f98ee92d4d53dc911552195868 Mon Sep 17 00:00:00 2001 From: Dylan Neve Date: Tue, 27 Jan 2026 11:17:31 +0000 Subject: [PATCH 13/58] fix(telegram): ignore message_thread_id for non-forum group sessions Regular Telegram groups (without Topics/Forums enabled) can send message_thread_id when users reply to messages. This was incorrectly being used to create separate session keys like '-123:topic:42', causing each reply chain to get its own conversation context. Now resolveTelegramForumThreadId only returns a thread ID when the chat is actually a forum (is_forum=true). For regular groups, the thread ID is ignored, ensuring all messages share the same session. DMs continue to use messageThreadId for thread sessions as before. --- .../bot-message-context.dm-threads.test.ts | 99 +++++++++++++++++++ src/telegram/bot-message-context.ts | 6 +- src/telegram/bot-native-commands.ts | 3 +- src/telegram/bot.ts | 3 +- src/telegram/bot/helpers.test.ts | 22 +++++ src/telegram/bot/helpers.ts | 15 ++- 6 files changed, 142 insertions(+), 6 deletions(-) diff --git a/src/telegram/bot-message-context.dm-threads.test.ts b/src/telegram/bot-message-context.dm-threads.test.ts index ff6a8a837..d710e0b1b 100644 --- a/src/telegram/bot-message-context.dm-threads.test.ts +++ b/src/telegram/bot-message-context.dm-threads.test.ts @@ -70,3 +70,102 @@ describe("buildTelegramMessageContext dm thread sessions", () => { expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main"); }); }); + +describe("buildTelegramMessageContext group sessions without forum", () => { + const baseConfig = { + agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" } }, + channels: { telegram: {} }, + messages: { groupChat: { mentionPatterns: [] } }, + } as never; + + const buildContext = async (message: Record) => + await buildTelegramMessageContext({ + primaryCtx: { + message, + me: { id: 7, username: "bot" }, + } as never, + allMedia: [], + storeAllowFrom: [], + options: { forceWasMentioned: true }, + bot: { + api: { + sendChatAction: vi.fn(), + setMessageReaction: vi.fn(), + }, + } as never, + cfg: baseConfig, + account: { accountId: "default" } as never, + historyLimit: 0, + groupHistories: new Map(), + dmPolicy: "open", + allowFrom: [], + groupAllowFrom: [], + ackReactionScope: "off", + logger: { info: vi.fn() }, + resolveGroupActivation: () => true, + resolveGroupRequireMention: () => false, + resolveTelegramGroupConfig: () => ({ + groupConfig: { requireMention: false }, + topicConfig: undefined, + }), + }); + + it("ignores message_thread_id for regular groups (not forums)", async () => { + // When someone replies to a message in a non-forum group, Telegram sends + // message_thread_id but this should NOT create a separate session + const ctx = await buildContext({ + message_id: 1, + chat: { id: -1001234567890, type: "supergroup", title: "Test Group" }, + date: 1700000000, + text: "@bot hello", + message_thread_id: 42, // This is a reply thread, NOT a forum topic + from: { id: 42, first_name: "Alice" }, + }); + + expect(ctx).not.toBeNull(); + // Session key should NOT include :topic:42 + expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890"); + // MessageThreadId should be undefined (not a forum) + expect(ctx?.ctxPayload?.MessageThreadId).toBeUndefined(); + }); + + it("keeps same session for regular group with and without message_thread_id", async () => { + const ctxWithThread = await buildContext({ + message_id: 1, + chat: { id: -1001234567890, type: "supergroup", title: "Test Group" }, + date: 1700000000, + text: "@bot hello", + message_thread_id: 42, + from: { id: 42, first_name: "Alice" }, + }); + + const ctxWithoutThread = await buildContext({ + message_id: 2, + chat: { id: -1001234567890, type: "supergroup", title: "Test Group" }, + date: 1700000001, + text: "@bot world", + from: { id: 42, first_name: "Alice" }, + }); + + expect(ctxWithThread).not.toBeNull(); + expect(ctxWithoutThread).not.toBeNull(); + // Both messages should use the same session key + expect(ctxWithThread?.ctxPayload?.SessionKey).toBe(ctxWithoutThread?.ctxPayload?.SessionKey); + }); + + it("uses topic session for forum groups with message_thread_id", async () => { + const ctx = await buildContext({ + message_id: 1, + chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true }, + date: 1700000000, + text: "@bot hello", + message_thread_id: 99, + from: { id: 42, first_name: "Alice" }, + }); + + expect(ctx).not.toBeNull(); + // Session key SHOULD include :topic:99 for forums + expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890:topic:99"); + expect(ctx?.ctxPayload?.MessageThreadId).toBe(99); + }); +}); diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index aa6dcd88b..832a4413d 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -173,7 +173,8 @@ export const buildTelegramMessageContext = async ({ }, }); const baseSessionKey = route.sessionKey; - const dmThreadId = !isGroup ? resolvedThreadId : undefined; + // DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums) + const dmThreadId = !isGroup ? messageThreadId : undefined; const threadKeys = dmThreadId != null ? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) }) @@ -601,7 +602,8 @@ export const buildTelegramMessageContext = async ({ Sticker: allMedia[0]?.stickerMetadata, ...(locationData ? toLocationContext(locationData) : undefined), CommandAuthorized: commandAuthorized, - MessageThreadId: resolvedThreadId, + // For groups: use resolvedThreadId (forum topics only); for DMs: use raw messageThreadId + MessageThreadId: isGroup ? resolvedThreadId : messageThreadId, IsForum: isForum, // Originating channel for reply routing. OriginatingChannel: "telegram" as const, diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 0dd372c3e..6b8bfba01 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -421,7 +421,8 @@ export const registerTelegramNativeCommands = ({ }, }); const baseSessionKey = route.sessionKey; - const dmThreadId = !isGroup ? resolvedThreadId : undefined; + // DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums) + const dmThreadId = !isGroup ? messageThreadId : undefined; const threadKeys = dmThreadId != null ? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) }) diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 655e1b427..c41abb34b 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -427,7 +427,8 @@ export function createTelegramBot(opts: TelegramBotOptions) { peer: { kind: isGroup ? "group" : "dm", id: peerId }, }); const baseSessionKey = route.sessionKey; - const dmThreadId = !isGroup ? resolvedThreadId : undefined; + // DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums) + const dmThreadId = !isGroup ? messageThreadId : undefined; const threadKeys = dmThreadId != null ? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) }) diff --git a/src/telegram/bot/helpers.test.ts b/src/telegram/bot/helpers.test.ts index 60fbba0dc..6b363933d 100644 --- a/src/telegram/bot/helpers.test.ts +++ b/src/telegram/bot/helpers.test.ts @@ -3,8 +3,30 @@ import { buildTelegramThreadParams, buildTypingThreadParams, normalizeForwardedContext, + resolveTelegramForumThreadId, } from "./helpers.js"; +describe("resolveTelegramForumThreadId", () => { + it("returns undefined for non-forum groups even with messageThreadId", () => { + // Reply threads in regular groups should not create separate sessions + expect(resolveTelegramForumThreadId({ isForum: false, messageThreadId: 42 })).toBeUndefined(); + }); + + it("returns undefined for non-forum groups without messageThreadId", () => { + expect(resolveTelegramForumThreadId({ isForum: false, messageThreadId: undefined })).toBeUndefined(); + expect(resolveTelegramForumThreadId({ isForum: undefined, messageThreadId: 99 })).toBeUndefined(); + }); + + it("returns General topic (1) for forum groups without messageThreadId", () => { + expect(resolveTelegramForumThreadId({ isForum: true, messageThreadId: undefined })).toBe(1); + expect(resolveTelegramForumThreadId({ isForum: true, messageThreadId: null })).toBe(1); + }); + + it("returns the topic id for forum groups with messageThreadId", () => { + expect(resolveTelegramForumThreadId({ isForum: true, messageThreadId: 99 })).toBe(99); + }); +}); + describe("buildTelegramThreadParams", () => { it("omits General topic thread id for message sends", () => { expect(buildTelegramThreadParams(1)).toBeUndefined(); diff --git a/src/telegram/bot/helpers.ts b/src/telegram/bot/helpers.ts index 19b8e76c0..cd57392c0 100644 --- a/src/telegram/bot/helpers.ts +++ b/src/telegram/bot/helpers.ts @@ -13,14 +13,25 @@ import type { const TELEGRAM_GENERAL_TOPIC_ID = 1; +/** + * Resolve the thread ID for Telegram forum topics. + * For non-forum groups, returns undefined even if messageThreadId is present + * (reply threads in regular groups should not create separate sessions). + * For forum groups, returns the topic ID (or General topic ID=1 if unspecified). + */ export function resolveTelegramForumThreadId(params: { isForum?: boolean; messageThreadId?: number | null; }) { - if (params.isForum && params.messageThreadId == null) { + // Non-forum groups: ignore message_thread_id (reply threads are not real topics) + if (!params.isForum) { + return undefined; + } + // Forum groups: use the topic ID, defaulting to General topic + if (params.messageThreadId == null) { return TELEGRAM_GENERAL_TOPIC_ID; } - return params.messageThreadId ?? undefined; + return params.messageThreadId; } /** From 14e4b88bf0650ab1cf8a967974c8086935f50001 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 28 Jan 2026 09:31:04 +0530 Subject: [PATCH 14/58] fix: keep telegram dm thread sessions (#2731) (thanks @dylanneve1) --- CHANGELOG.md | 1 + src/telegram/bot-native-commands.ts | 14 +++++++++----- ...-telegram-bot.installs-grammy-throttler.test.ts | 9 +++++++-- src/telegram/bot.test.ts | 9 +++++++-- src/telegram/bot.ts | 9 +++++---- 5 files changed, 29 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a68c3769..c6819e29a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,7 @@ Status: beta. - Agents: release session locks on process termination and cover more signals. (#2483) Thanks @janeexai. - Agents: skip cooldowned providers during model failover. (#2143) Thanks @YiWang24. - Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss. +- Telegram: ignore non-forum group message_thread_id while preserving DM thread sessions. (#2731) Thanks @dylanneve1. - Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos. - Telegram: centralize API error logging for delivery and bot calls. (#2492) Thanks @altryne. - Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default. diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 6b8bfba01..3415ea927 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -360,6 +360,8 @@ export const registerTelegramNativeCommands = ({ topicConfig, commandAuthorized, } = auth; + const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id; + const threadIdForSend = isGroup ? resolvedThreadId : messageThreadId; const commandDefinition = findCommandByNativeName(command.name, "telegram"); const rawText = ctx.match?.trim() ?? ""; @@ -406,7 +408,7 @@ export const registerTelegramNativeCommands = ({ fn: () => bot.api.sendMessage(chatId, title, { ...(replyMarkup ? { reply_markup: replyMarkup } : {}), - ...(resolvedThreadId != null ? { message_thread_id: resolvedThreadId } : {}), + ...(threadIdForSend != null ? { message_thread_id: threadIdForSend } : {}), }), }); return; @@ -467,7 +469,7 @@ export const registerTelegramNativeCommands = ({ CommandSource: "native" as const, SessionKey: `telegram:slash:${senderId || chatId}`, CommandTargetSessionKey: sessionKey, - MessageThreadId: resolvedThreadId, + MessageThreadId: threadIdForSend, IsForum: isForum, // Originating context for sub-agent announce routing OriginatingChannel: "telegram" as const, @@ -494,7 +496,7 @@ export const registerTelegramNativeCommands = ({ bot, replyToMode, textLimit, - messageThreadId: resolvedThreadId, + messageThreadId: threadIdForSend, tableMode, chunkMode, linkPreview: telegramCfg.linkPreview, @@ -542,7 +544,9 @@ export const registerTelegramNativeCommands = ({ requireAuth: match.command.requireAuth !== false, }); if (!auth) return; - const { resolvedThreadId, senderId, commandAuthorized } = auth; + const { resolvedThreadId, senderId, commandAuthorized, isGroup } = auth; + const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id; + const threadIdForSend = isGroup ? resolvedThreadId : messageThreadId; const result = await executePluginCommand({ command: match.command, @@ -568,7 +572,7 @@ export const registerTelegramNativeCommands = ({ bot, replyToMode, textLimit, - messageThreadId: resolvedThreadId, + messageThreadId: threadIdForSend, tableMode, chunkMode, linkPreview: telegramCfg.linkPreview, diff --git a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts index bf94e4f6f..c3844ac88 100644 --- a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts +++ b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts @@ -238,12 +238,17 @@ describe("createTelegramBot", () => { expect(getTelegramSequentialKey({ message: { chat: { id: 123 } } })).toBe("telegram:123"); expect( getTelegramSequentialKey({ - message: { chat: { id: 123 }, message_thread_id: 9 }, + message: { chat: { id: 123, type: "private" }, message_thread_id: 9 }, }), ).toBe("telegram:123:topic:9"); expect( getTelegramSequentialKey({ - message: { chat: { id: 123, is_forum: true } }, + message: { chat: { id: 123, type: "supergroup" }, message_thread_id: 9 }, + }), + ).toBe("telegram:123"); + expect( + getTelegramSequentialKey({ + message: { chat: { id: 123, type: "supergroup", is_forum: true } }, }), ).toBe("telegram:123:topic:1"); expect( diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 75dd32faf..c075174fb 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -340,12 +340,17 @@ describe("createTelegramBot", () => { expect(getTelegramSequentialKey({ message: { chat: { id: 123 } } })).toBe("telegram:123"); expect( getTelegramSequentialKey({ - message: { chat: { id: 123 }, message_thread_id: 9 }, + message: { chat: { id: 123, type: "private" }, message_thread_id: 9 }, }), ).toBe("telegram:123:topic:9"); expect( getTelegramSequentialKey({ - message: { chat: { id: 123, is_forum: true } }, + message: { chat: { id: 123, type: "supergroup" }, message_thread_id: 9 }, + }), + ).toBe("telegram:123"); + expect( + getTelegramSequentialKey({ + message: { chat: { id: 123, type: "supergroup", is_forum: true } }, }), ).toBe("telegram:123:topic:1"); expect( diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index c41abb34b..ae21d10da 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -94,11 +94,12 @@ export function getTelegramSequentialKey(ctx: { if (typeof chatId === "number") return `telegram:${chatId}:control`; return "telegram:control"; } + const isGroup = msg?.chat?.type === "group" || msg?.chat?.type === "supergroup"; + const messageThreadId = msg?.message_thread_id; const isForum = (msg?.chat as { is_forum?: boolean } | undefined)?.is_forum; - const threadId = resolveTelegramForumThreadId({ - isForum, - messageThreadId: msg?.message_thread_id, - }); + const threadId = isGroup + ? resolveTelegramForumThreadId({ isForum, messageThreadId }) + : messageThreadId; if (typeof chatId === "number") { return threadId != null ? `telegram:${chatId}:topic:${threadId}` : `telegram:${chatId}`; } From b01612c2622460b9cad02e934a1ba2c65a77abe5 Mon Sep 17 00:00:00 2001 From: Shadow Date: Tue, 27 Jan 2026 22:47:17 -0600 Subject: [PATCH 15/58] Discord: gate username lookups --- CHANGELOG.md | 1 + src/discord/targets.ts | 43 +++++++++++++++++++++++++++++------------- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6819e29a..3e11f1ef7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,7 @@ Status: beta. - Web UI: auto-expand the chat compose textarea while typing (with sensible max height). (#2950) Thanks @shivamraut101. - Gateway: prevent crashes on transient network errors (fetch failures, timeouts, DNS). Added fatal error detection to only exit on truly critical errors. Fixes #2895, #2879, #2873. (#2980) Thanks @elliotsecops. - Agents: guard channel tool listActions to avoid plugin crashes. (#2859) Thanks @mbelinky. +- Discord: avoid resolving bare channel names to user DMs when a username matches. Thanks @thewilloftheshadow. - Providers: update MiniMax API endpoint and compatibility mode. (#3064) Thanks @hlbbbbbbb. - Telegram: treat more network errors as recoverable in polling. (#3013) Thanks @ryancontent. - Discord: resolve usernames to user IDs for outbound messages. (#2649) Thanks @nonggialiang. diff --git a/src/discord/targets.ts b/src/discord/targets.ts index 311955182..00514a0ff 100644 --- a/src/discord/targets.ts +++ b/src/discord/targets.ts @@ -81,11 +81,14 @@ export async function resolveDiscordTarget( const trimmed = raw.trim(); if (!trimmed) return undefined; - // If already a known format, parse directly - const directParse = parseDiscordTarget(trimmed, options); - if (directParse && directParse.kind !== "channel" && !isLikelyUsername(trimmed)) { + const shouldLookup = isExplicitUserLookup(trimmed, options); + const directParse = safeParseDiscordTarget(trimmed, options); + if (directParse && directParse.kind !== "channel") { return directParse; } + if (!shouldLookup) { + return directParse ?? parseDiscordTarget(trimmed, options); + } // Try to resolve as a username via directory lookup try { @@ -110,15 +113,29 @@ export async function resolveDiscordTarget( return parseDiscordTarget(trimmed, options); } -/** - * Check if a string looks like a Discord username (not a mention, prefix, or ID). - * Usernames typically don't start with special characters except underscore. - */ -function isLikelyUsername(input: string): boolean { - // Skip if it's already a known format - if (/^(user:|channel:|discord:|@|<@!?)|[\d]+$/.test(input)) { - return false; +function safeParseDiscordTarget( + input: string, + options: DiscordTargetParseOptions, +): MessagingTarget | undefined { + try { + return parseDiscordTarget(input, options); + } catch { + return undefined; } - // Likely a username if it doesn't match known patterns - return true; +} + +function isExplicitUserLookup(input: string, options: DiscordTargetParseOptions): boolean { + if (/^<@!?(\d+)>$/.test(input)) { + return true; + } + if (/^(user:|discord:)/.test(input)) { + return true; + } + if (input.startsWith("@")) { + return true; + } + if (/^\d+$/.test(input)) { + return options.defaultKind === "user"; + } + return false; } From 61ab348dd3e0170a762f1563bb4d5f0c346670f9 Mon Sep 17 00:00:00 2001 From: Shadow Date: Tue, 27 Jan 2026 22:56:12 -0600 Subject: [PATCH 16/58] Discord: fix target type imports --- CHANGELOG.md | 1 + src/discord/targets.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e11f1ef7..ac2e62360 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,7 @@ Status: beta. - Gateway: prevent crashes on transient network errors (fetch failures, timeouts, DNS). Added fatal error detection to only exit on truly critical errors. Fixes #2895, #2879, #2873. (#2980) Thanks @elliotsecops. - Agents: guard channel tool listActions to avoid plugin crashes. (#2859) Thanks @mbelinky. - Discord: avoid resolving bare channel names to user DMs when a username matches. Thanks @thewilloftheshadow. +- Discord: fix directory config type import for target resolution. Thanks @thewilloftheshadow. - Providers: update MiniMax API endpoint and compatibility mode. (#3064) Thanks @hlbbbbbbb. - Telegram: treat more network errors as recoverable in polling. (#3013) Thanks @ryancontent. - Discord: resolve usernames to user IDs for outbound messages. (#2649) Thanks @nonggialiang. diff --git a/src/discord/targets.ts b/src/discord/targets.ts index 00514a0ff..e8b1c3943 100644 --- a/src/discord/targets.ts +++ b/src/discord/targets.ts @@ -5,10 +5,10 @@ import { type MessagingTarget, type MessagingTargetKind, type MessagingTargetParseOptions, - type DirectoryConfigParams, - type ChannelDirectoryEntry, } from "../channels/targets.js"; +import type { DirectoryConfigParams } from "../channels/plugins/directory-config.js"; + import { listDiscordDirectoryPeersLive } from "./directory-live.js"; import { resolveDiscordAccount } from "./accounts.js"; From 6fc3ca4996c97f9ccc51583b30254a827bc2467a Mon Sep 17 00:00:00 2001 From: Shadow Date: Tue, 27 Jan 2026 23:17:22 -0600 Subject: [PATCH 17/58] CI: add auto-response labels --- .github/workflows/auto-response.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml index b610e1718..6d9f55903 100644 --- a/.github/workflows/auto-response.yml +++ b/.github/workflows/auto-response.yml @@ -24,13 +24,26 @@ jobs: with: github-token: ${{ steps.app-token.outputs.token }} script: | + // Labels prefixed with "r:" are auto-response triggers. const rules = [ { - label: "skill-clawdhub", + label: "r: skill", close: true, message: "Thanks for the contribution! New skills should be published to Clawdhub for everyone to use. We’re keeping the core lean on skills, so I’m closing this out.", }, + { + label: "r: support", + close: true, + message: + "Please use our support server https://molt.bot/discord and ask in #help or #users-helping-users to resolve this, or follow the stuck FAQ at https://docs.molt.bot/help/faq#im-stuck-whats-the-fastest-way-to-get-unstuck.", + }, + { + label: "r: third-party-extension", + close: true, + message: + "This would be better made as a third-party extension with our SDK that you maintain yourself. Docs: https://docs.molt.bot/plugin.", + }, ]; const labelName = context.payload.label?.name; From cd72b80011b6d172492d59d5eb6107cc76beff3e Mon Sep 17 00:00:00 2001 From: Jarvis Date: Wed, 28 Jan 2026 04:22:22 +0000 Subject: [PATCH 18/58] fix(discord): add missing type exports and fix unused imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Re-export DirectoryConfigParams and ChannelDirectoryEntry from channels/targets - Remove unused ChannelDirectoryEntry and resolveDiscordAccount imports - Fix parseDiscordTarget calls to not pass incompatible options type - Fix unused catch parameter Fixes CI build failures on main. 🤖 Generated with Claude Code --- src/channels/targets.ts | 3 +++ src/discord/targets.ts | 3 +-- src/telegram/bot/helpers.test.ts | 8 ++++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/channels/targets.ts b/src/channels/targets.ts index 77ab755b7..7c9d9cf60 100644 --- a/src/channels/targets.ts +++ b/src/channels/targets.ts @@ -1,3 +1,6 @@ +export type { DirectoryConfigParams } from "./plugins/directory-config.js"; +export type { ChannelDirectoryEntry } from "./plugins/types.js"; + export type MessagingTargetKind = "user" | "channel"; export type MessagingTarget = { diff --git a/src/discord/targets.ts b/src/discord/targets.ts index e8b1c3943..49c46e3ed 100644 --- a/src/discord/targets.ts +++ b/src/discord/targets.ts @@ -10,7 +10,6 @@ import { import type { DirectoryConfigParams } from "../channels/plugins/directory-config.js"; import { listDiscordDirectoryPeersLive } from "./directory-live.js"; -import { resolveDiscordAccount } from "./accounts.js"; export type DiscordTargetKind = MessagingTargetKind; @@ -104,7 +103,7 @@ export async function resolveDiscordTarget( const userId = match.id.replace(/^user:/, ""); return buildMessagingTarget("user", userId, trimmed); } - } catch (error) { + } catch { // Directory lookup failed - fall through to parse as-is // This preserves existing behavior for channel names } diff --git a/src/telegram/bot/helpers.test.ts b/src/telegram/bot/helpers.test.ts index 6b363933d..8e90bb520 100644 --- a/src/telegram/bot/helpers.test.ts +++ b/src/telegram/bot/helpers.test.ts @@ -13,8 +13,12 @@ describe("resolveTelegramForumThreadId", () => { }); it("returns undefined for non-forum groups without messageThreadId", () => { - expect(resolveTelegramForumThreadId({ isForum: false, messageThreadId: undefined })).toBeUndefined(); - expect(resolveTelegramForumThreadId({ isForum: undefined, messageThreadId: 99 })).toBeUndefined(); + expect( + resolveTelegramForumThreadId({ isForum: false, messageThreadId: undefined }), + ).toBeUndefined(); + expect( + resolveTelegramForumThreadId({ isForum: undefined, messageThreadId: 99 }), + ).toBeUndefined(); }); it("returns General topic (1) for forum groups without messageThreadId", () => { From f897f17c6e8fcd5a3e9aa28d650aec7c33578e03 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Wed, 28 Jan 2026 04:32:21 +0000 Subject: [PATCH 19/58] test: update MiniMax API URL expectation to match #3064 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MiniMax provider config was updated to use api.minimax.chat instead of api.minimax.io in PR #3064, but the test expectation was not updated. 🤖 Generated with Claude Code --- src/agents/tools/image-tool.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agents/tools/image-tool.test.ts b/src/agents/tools/image-tool.test.ts index 0e4579d6d..2b4e1aea1 100644 --- a/src/agents/tools/image-tool.test.ts +++ b/src/agents/tools/image-tool.test.ts @@ -275,7 +275,7 @@ describe("image tool MiniMax VLM routing", () => { expect(fetch).toHaveBeenCalledTimes(1); const [url, init] = fetch.mock.calls[0]; - expect(String(url)).toBe("https://api.minimax.io/v1/coding_plan/vlm"); + expect(String(url)).toBe("https://api.minimax.chat/v1/coding_plan/vlm"); expect(init?.method).toBe("POST"); expect(String((init?.headers as Record)?.Authorization)).toBe( "Bearer minimax-test", From 93c2d6539870b8a0e3455032831d40207589f446 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 28 Jan 2026 11:01:03 +0530 Subject: [PATCH 20/58] fix: restore discord username lookup and align minimax test (#3131) (thanks @bonald) --- CHANGELOG.md | 2 ++ ...s-writing-models-json-no-env-token.test.ts | 2 +- src/discord/targets.ts | 25 +++++++++++++++---- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac2e62360..e16c962a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,8 @@ Status: beta. - **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed). ### Fixes +- Discord: restore username directory lookup in target resolution. (#3131) Thanks @bonald. +- Agents: align MiniMax base URL test expectation with default provider config. (#3131) Thanks @bonald. - Agents: prevent retries on oversized image errors and surface size limits. (#2871) Thanks @Suksham-sharma. - Agents: inherit provider baseUrl/api for inline models. (#2740) Thanks @lploc94. - Memory Search: keep auto provider model defaults and only include remote when configured. (#2576) Thanks @papago2355. diff --git a/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts b/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts index 270b5fb02..fef8fa6a4 100644 --- a/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts +++ b/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts @@ -136,7 +136,7 @@ describe("models-config", () => { } >; }; - expect(parsed.providers.minimax?.baseUrl).toBe("https://api.minimax.io/anthropic"); + expect(parsed.providers.minimax?.baseUrl).toBe("https://api.minimax.chat/v1"); expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY"); const ids = parsed.providers.minimax?.models?.map((model) => model.id); expect(ids).toContain("MiniMax-M2.1"); diff --git a/src/discord/targets.ts b/src/discord/targets.ts index 49c46e3ed..c6f56cf53 100644 --- a/src/discord/targets.ts +++ b/src/discord/targets.ts @@ -80,13 +80,15 @@ export async function resolveDiscordTarget( const trimmed = raw.trim(); if (!trimmed) return undefined; - const shouldLookup = isExplicitUserLookup(trimmed, options); - const directParse = safeParseDiscordTarget(trimmed, options); - if (directParse && directParse.kind !== "channel") { + const parseOptions: DiscordTargetParseOptions = {}; + const likelyUsername = isLikelyUsername(trimmed); + const shouldLookup = isExplicitUserLookup(trimmed, parseOptions) || likelyUsername; + const directParse = safeParseDiscordTarget(trimmed, parseOptions); + if (directParse && directParse.kind !== "channel" && !likelyUsername) { return directParse; } if (!shouldLookup) { - return directParse ?? parseDiscordTarget(trimmed, options); + return directParse ?? parseDiscordTarget(trimmed, parseOptions); } // Try to resolve as a username via directory lookup @@ -109,7 +111,7 @@ export async function resolveDiscordTarget( } // Fallback to original parsing (for channels, etc.) - return parseDiscordTarget(trimmed, options); + return parseDiscordTarget(trimmed, parseOptions); } function safeParseDiscordTarget( @@ -138,3 +140,16 @@ function isExplicitUserLookup(input: string, options: DiscordTargetParseOptions) } return false; } + +/** + * Check if a string looks like a Discord username (not a mention, prefix, or ID). + * Usernames typically don't start with special characters except underscore. + */ +function isLikelyUsername(input: string): boolean { + // Skip if it's already a known format + if (/^(user:|channel:|discord:|@|<@!?)|[\d]+$/.test(input)) { + return false; + } + // Likely a username if it doesn't match known patterns + return true; +} From d499b148423e896114ccd718d7d56687ca56fc8e Mon Sep 17 00:00:00 2001 From: Jarvis Deploy Date: Tue, 27 Jan 2026 21:51:23 -0500 Subject: [PATCH 21/58] feat(routing): add per-account-channel-peer session scope Adds a new dmScope option that includes accountId in session keys, enabling isolated sessions per channel account for multi-bot setups. - Add 'per-account-channel-peer' to DmScope type - Update session key generation to include accountId - Pass accountId through routing chain - Add tests for new routing behavior (13/13 passing) Closes #3094 Co-authored-by: Sebastian Almeida <89653954+SebastianAlmeida@users.noreply.github.com> --- src/config/types.base.ts | 2 +- src/config/zod-schema.session.ts | 7 ++++++- src/infra/outbound/outbound-session.ts | 19 +++++++++++++++++++ src/routing/resolve-route.test.ts | 26 ++++++++++++++++++++++++++ src/routing/resolve-route.ts | 5 ++++- src/routing/session-key.ts | 8 +++++++- 6 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/config/types.base.ts b/src/config/types.base.ts index cc805e8ec..e7da1ecd8 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -3,7 +3,7 @@ import type { NormalizedChatType } from "../channels/chat-type.js"; export type ReplyMode = "text" | "command"; export type TypingMode = "never" | "instant" | "thinking" | "message"; export type SessionScope = "per-sender" | "global"; -export type DmScope = "main" | "per-peer" | "per-channel-peer"; +export type DmScope = "main" | "per-peer" | "per-channel-peer" | "per-account-channel-peer"; export type ReplyToMode = "off" | "first" | "all"; export type GroupPolicy = "open" | "disabled" | "allowlist"; export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled"; diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index b9e7b42cc..4412f5515 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -20,7 +20,12 @@ export const SessionSchema = z .object({ scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(), dmScope: z - .union([z.literal("main"), z.literal("per-peer"), z.literal("per-channel-peer")]) + .union([ + z.literal("main"), + z.literal("per-peer"), + z.literal("per-channel-peer"), + z.literal("per-account-channel-peer"), + ]) .optional(), identityLinks: z.record(z.string(), z.array(z.string())).optional(), resetTriggers: z.array(z.string()).optional(), diff --git a/src/infra/outbound/outbound-session.ts b/src/infra/outbound/outbound-session.ts index c74abc509..9c12fab96 100644 --- a/src/infra/outbound/outbound-session.ts +++ b/src/infra/outbound/outbound-session.ts @@ -103,11 +103,13 @@ function buildBaseSessionKey(params: { cfg: MoltbotConfig; agentId: string; channel: ChannelId; + accountId?: string | null; peer: RoutePeer; }): string { return buildAgentSessionKey({ agentId: params.agentId, channel: params.channel, + accountId: params.accountId, peer: params.peer, dmScope: params.cfg.session?.dmScope ?? "main", identityLinks: params.cfg.session?.identityLinks, @@ -200,6 +202,7 @@ async function resolveSlackSession( cfg: params.cfg, agentId: params.agentId, channel: "slack", + accountId: params.accountId, peer, }); const threadId = normalizeThreadId(params.threadId ?? params.replyToId); @@ -237,6 +240,7 @@ function resolveDiscordSession( cfg: params.cfg, agentId: params.agentId, channel: "discord", + accountId: params.accountId, peer, }); const explicitThreadId = normalizeThreadId(params.threadId); @@ -285,6 +289,7 @@ function resolveTelegramSession( cfg: params.cfg, agentId: params.agentId, channel: "telegram", + accountId: params.accountId, peer, }); return { @@ -312,6 +317,7 @@ function resolveWhatsAppSession( cfg: params.cfg, agentId: params.agentId, channel: "whatsapp", + accountId: params.accountId, peer, }); return { @@ -337,6 +343,7 @@ function resolveSignalSession( cfg: params.cfg, agentId: params.agentId, channel: "signal", + accountId: params.accountId, peer, }); return { @@ -371,6 +378,7 @@ function resolveSignalSession( cfg: params.cfg, agentId: params.agentId, channel: "signal", + accountId: params.accountId, peer, }); return { @@ -395,6 +403,7 @@ function resolveIMessageSession( cfg: params.cfg, agentId: params.agentId, channel: "imessage", + accountId: params.accountId, peer, }); return { @@ -419,6 +428,7 @@ function resolveIMessageSession( cfg: params.cfg, agentId: params.agentId, channel: "imessage", + accountId: params.accountId, peer, }); const toPrefix = @@ -450,6 +460,7 @@ function resolveMatrixSession( cfg: params.cfg, agentId: params.agentId, channel: "matrix", + accountId: params.accountId, peer, }); return { @@ -483,6 +494,7 @@ function resolveMSTeamsSession( cfg: params.cfg, agentId: params.agentId, channel: "msteams", + accountId: params.accountId, peer, }); return { @@ -517,6 +529,7 @@ function resolveMattermostSession( cfg: params.cfg, agentId: params.agentId, channel: "mattermost", + accountId: params.accountId, peer, }); const threadId = normalizeThreadId(params.replyToId ?? params.threadId); @@ -561,6 +574,7 @@ function resolveBlueBubblesSession( cfg: params.cfg, agentId: params.agentId, channel: "bluebubbles", + accountId: params.accountId, peer, }); return { @@ -586,6 +600,7 @@ function resolveNextcloudTalkSession( cfg: params.cfg, agentId: params.agentId, channel: "nextcloud-talk", + accountId: params.accountId, peer, }); return { @@ -612,6 +627,7 @@ function resolveZaloSession( cfg: params.cfg, agentId: params.agentId, channel: "zalo", + accountId: params.accountId, peer, }); return { @@ -639,6 +655,7 @@ function resolveZalouserSession( cfg: params.cfg, agentId: params.agentId, channel: "zalouser", + accountId: params.accountId, peer, }); return { @@ -661,6 +678,7 @@ function resolveNostrSession( cfg: params.cfg, agentId: params.agentId, channel: "nostr", + accountId: params.accountId, peer, }); return { @@ -719,6 +737,7 @@ function resolveTlonSession( cfg: params.cfg, agentId: params.agentId, channel: "tlon", + accountId: params.accountId, peer, }); return { diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts index 6a3366e97..aed0fa755 100644 --- a/src/routing/resolve-route.test.ts +++ b/src/routing/resolve-route.test.ts @@ -227,3 +227,29 @@ describe("resolveAgentRoute", () => { expect(route.sessionKey).toBe("agent:home:main"); }); }); + +test("dmScope=per-account-channel-peer isolates DM sessions per account, channel and sender", () => { + const cfg: MoltbotConfig = { + session: { dmScope: "per-account-channel-peer" }, + }; + const route = resolveAgentRoute({ + cfg, + channel: "telegram", + accountId: "tasks", + peer: { kind: "dm", id: "7550356539" }, + }); + expect(route.sessionKey).toBe("agent:main:telegram:tasks:dm:7550356539"); +}); + +test("dmScope=per-account-channel-peer uses default accountId when not provided", () => { + const cfg: MoltbotConfig = { + session: { dmScope: "per-account-channel-peer" }, + }; + const route = resolveAgentRoute({ + cfg, + channel: "telegram", + accountId: null, + peer: { kind: "dm", id: "7550356539" }, + }); + expect(route.sessionKey).toBe("agent:main:telegram:default:dm:7550356539"); +}); diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts index 473dc61f2..0c63f77c8 100644 --- a/src/routing/resolve-route.ts +++ b/src/routing/resolve-route.ts @@ -69,9 +69,10 @@ function matchesAccountId(match: string | undefined, actual: string): boolean { export function buildAgentSessionKey(params: { agentId: string; channel: string; + accountId?: string | null; peer?: RoutePeer | null; /** DM session scope. */ - dmScope?: "main" | "per-peer" | "per-channel-peer"; + dmScope?: "main" | "per-peer" | "per-channel-peer" | "per-account-channel-peer"; identityLinks?: Record; }): string { const channel = normalizeToken(params.channel) || "unknown"; @@ -80,6 +81,7 @@ export function buildAgentSessionKey(params: { agentId: params.agentId, mainKey: DEFAULT_MAIN_KEY, channel, + accountId: params.accountId, peerKind: peer?.kind ?? "dm", peerId: peer ? normalizeId(peer.id) || "unknown" : null, dmScope: params.dmScope, @@ -160,6 +162,7 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR const sessionKey = buildAgentSessionKey({ agentId: resolvedAgentId, channel, + accountId, peer, dmScope, identityLinks, diff --git a/src/routing/session-key.ts b/src/routing/session-key.ts index 7f9f209ed..320ffeb83 100644 --- a/src/routing/session-key.ts +++ b/src/routing/session-key.ts @@ -111,11 +111,12 @@ export function buildAgentPeerSessionKey(params: { agentId: string; mainKey?: string | undefined; channel: string; + accountId?: string | null; peerKind?: "dm" | "group" | "channel" | null; peerId?: string | null; identityLinks?: Record; /** DM session scope. */ - dmScope?: "main" | "per-peer" | "per-channel-peer"; + dmScope?: "main" | "per-peer" | "per-channel-peer" | "per-account-channel-peer"; }): string { const peerKind = params.peerKind ?? "dm"; if (peerKind === "dm") { @@ -131,6 +132,11 @@ export function buildAgentPeerSessionKey(params: { }); if (linkedPeerId) peerId = linkedPeerId; peerId = peerId.toLowerCase(); + if (dmScope === "per-account-channel-peer" && peerId) { + const channel = (params.channel ?? "").trim().toLowerCase() || "unknown"; + const accountId = normalizeAccountId(params.accountId); + return `agent:${normalizeAgentId(params.agentId)}:${channel}:${accountId}:dm:${peerId}`; + } if (dmScope === "per-channel-peer" && peerId) { const channel = (params.channel ?? "").trim().toLowerCase() || "unknown"; return `agent:${normalizeAgentId(params.agentId)}:${channel}:dm:${peerId}`; From b6a3a91edf528d8fcb750dd5387e247abcaf63d6 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 28 Jan 2026 11:41:28 +0530 Subject: [PATCH 22/58] fix: wire per-account dm scope guidance (#3095) (thanks @jarvis-sam) --- CHANGELOG.md | 1 + docs/cli/security.md | 2 +- docs/concepts/session.md | 6 ++++-- docs/gateway/configuration.md | 3 ++- docs/gateway/security/index.md | 2 +- src/commands/doctor-security.ts | 2 +- src/commands/onboard-channels.ts | 4 ++-- src/config/schema.ts | 2 +- src/security/audit.ts | 3 ++- src/web/auto-reply/monitor/broadcast.ts | 2 ++ 10 files changed, 17 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e16c962a4..17f957f07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ Status: beta. - Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999. - macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal. - CLI: use Node's module compile cache for faster startup. (#2808) Thanks @pi0. +- Routing: add per-account DM session scope and document multi-account isolation. (#3095) Thanks @jarvis-sam. ### Breaking - **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed). diff --git a/docs/cli/security.md b/docs/cli/security.md index 662181616..551debc99 100644 --- a/docs/cli/security.md +++ b/docs/cli/security.md @@ -20,5 +20,5 @@ moltbot security audit --deep moltbot security audit --fix ``` -The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` for shared inboxes. +The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` (or `per-account-channel-peer` for multi-account channels) for shared inboxes. It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled. diff --git a/docs/concepts/session.md b/docs/concepts/session.md index 58ac57145..b15b1a1ea 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -11,7 +11,8 @@ Use `session.dmScope` to control how **direct messages** are grouped: - `main` (default): all DMs share the main session for continuity. - `per-peer`: isolate by sender id across channels. - `per-channel-peer`: isolate by channel + sender (recommended for multi-user inboxes). -Use `session.identityLinks` to map provider-prefixed peer ids to a canonical identity so the same person shares a DM session across channels when using `per-peer` or `per-channel-peer`. +- `per-account-channel-peer`: isolate by account + channel + sender (recommended for multi-account inboxes). +Use `session.identityLinks` to map provider-prefixed peer ids to a canonical identity so the same person shares a DM session across channels when using `per-peer`, `per-channel-peer`, or `per-account-channel-peer`. ## Gateway is the source of truth All session state is **owned by the gateway** (the “master” Moltbot). UI clients (macOS app, WebChat, etc.) must query the gateway for session lists and token counts instead of reading local files. @@ -44,6 +45,7 @@ the workspace is writable. See [Memory](/concepts/memory) and - Multiple phone numbers and channels can map to the same agent main key; they act as transports into one conversation. - `per-peer`: `agent::dm:`. - `per-channel-peer`: `agent:::dm:`. + - `per-account-channel-peer`: `agent::::dm:` (accountId defaults to `default`). - If `session.identityLinks` matches a provider-prefixed peer id (for example `telegram:123`), the canonical key replaces `` so the same person shares a session across channels. - Group chats isolate state: `agent:::group:` (rooms/channels use `agent:::channel:`). - Telegram forum topics append `:topic:` to the group id for isolation. @@ -94,7 +96,7 @@ Send these as standalone messages so they register. { session: { scope: "per-sender", // keep group keys separate - dmScope: "main", // DM continuity (set per-channel-peer for shared inboxes) + dmScope: "main", // DM continuity (set per-channel-peer/per-account-channel-peer for shared inboxes) identityLinks: { alice: ["telegram:123456789", "discord:987654321012345678"] }, diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 15261c809..1d270974d 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2657,7 +2657,8 @@ Fields: - `main`: all DMs share the main session for continuity. - `per-peer`: isolate DMs by sender id across channels. - `per-channel-peer`: isolate DMs per channel + sender (recommended for multi-user inboxes). -- `identityLinks`: map canonical ids to provider-prefixed peers so the same person shares a DM session across channels when using `per-peer` or `per-channel-peer`. + - `per-account-channel-peer`: isolate DMs per account + channel + sender (recommended for multi-account inboxes). +- `identityLinks`: map canonical ids to provider-prefixed peers so the same person shares a DM session across channels when using `per-peer`, `per-channel-peer`, or `per-account-channel-peer`. - Example: `alice: ["telegram:123456789", "discord:987654321012345678"]`. - `reset`: primary reset policy. Defaults to daily resets at 4:00 AM local time on the gateway host. - `mode`: `daily` or `idle` (default: `daily` when `reset` is present). diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index d29c3df48..a5d841c18 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -199,7 +199,7 @@ By default, Moltbot routes **all DMs into the main session** so your assistant h } ``` -This prevents cross-user context leakage while keeping group chats isolated. If the same person contacts you on multiple channels, use `session.identityLinks` to collapse those DM sessions into one canonical identity. See [Session Management](/concepts/session) and [Configuration](/gateway/configuration). +This prevents cross-user context leakage while keeping group chats isolated. If you run multiple accounts on the same channel, use `per-account-channel-peer` instead. If the same person contacts you on multiple channels, use `session.identityLinks` to collapse those DM sessions into one canonical identity. See [Session Management](/concepts/session) and [Configuration](/gateway/configuration). ## Allowlists (DM + groups) — terminology diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts index bf2c94da7..856b18bfb 100644 --- a/src/commands/doctor-security.ts +++ b/src/commands/doctor-security.ts @@ -124,7 +124,7 @@ export async function noteSecurityWarnings(cfg: MoltbotConfig) { if (dmScope === "main" && isMultiUserDm) { warnings.push( - `- ${params.label} DMs: multiple senders share the main session; set session.dmScope="per-channel-peer" to isolate sessions.`, + `- ${params.label} DMs: multiple senders share the main session; set session.dmScope="per-channel-peer" (or "per-account-channel-peer" for multi-account channels) to isolate sessions.`, ); } }; diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index e1f8dbe8e..27ec07de4 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -190,7 +190,7 @@ async function noteChannelPrimer( "DM security: default is pairing; unknown DMs get a pairing code.", `Approve with: ${formatCliCommand("moltbot pairing approve ")}`, 'Public DMs require dmPolicy="open" + allowFrom=["*"].', - 'Multi-user DMs: set session.dmScope="per-channel-peer" to isolate sessions.', + 'Multi-user DMs: set session.dmScope="per-channel-peer" (or "per-account-channel-peer" for multi-account channels) to isolate sessions.', `Docs: ${formatDocsLink("/start/pairing", "start/pairing")}`, "", ...channelLines, @@ -238,7 +238,7 @@ async function maybeConfigureDmPolicies(params: { `Approve: ${formatCliCommand(`moltbot pairing approve ${policy.channel} `)}`, `Allowlist DMs: ${policy.policyKey}="allowlist" + ${policy.allowFromKey} entries.`, `Public DMs: ${policy.policyKey}="open" + ${policy.allowFromKey} includes "*".`, - 'Multi-user DMs: set session.dmScope="per-channel-peer" to isolate sessions.', + 'Multi-user DMs: set session.dmScope="per-channel-peer" (or "per-account-channel-peer" for multi-account channels) to isolate sessions.', `Docs: ${formatDocsLink("/start/pairing", "start/pairing")}`, ].join("\n"), `${policy.label} DM access`, diff --git a/src/config/schema.ts b/src/config/schema.ts index 9b5ad8be6..b4ec8723b 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -591,7 +591,7 @@ const FIELD_HELP: Record = { "commands.restart": "Allow /restart and gateway restart tool actions (default: false).", "commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.", "session.dmScope": - 'DM session scoping: "main" keeps continuity; "per-peer" or "per-channel-peer" isolates DM history (recommended for shared inboxes).', + 'DM session scoping: "main" keeps continuity; "per-peer", "per-channel-peer", or "per-account-channel-peer" isolates DM history (recommended for shared inboxes/multi-account).', "session.identityLinks": "Map canonical identities to provider-prefixed peer IDs for DM session linking (example: telegram:123456).", "channels.telegram.configWrites": diff --git a/src/security/audit.ts b/src/security/audit.ts index 7aebd6928..681d14c1d 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -519,7 +519,8 @@ async function collectChannelSecurityFindings(params: { title: `${input.label} DMs share the main session`, detail: "Multiple DM senders currently share the main session, which can leak context across users.", - remediation: 'Set session.dmScope="per-channel-peer" to isolate DM sessions per sender.', + remediation: + 'Set session.dmScope="per-channel-peer" (or "per-account-channel-peer" for multi-account channels) to isolate DM sessions per sender.', }); } }; diff --git a/src/web/auto-reply/monitor/broadcast.ts b/src/web/auto-reply/monitor/broadcast.ts index ef76ce3b0..c8f84a048 100644 --- a/src/web/auto-reply/monitor/broadcast.ts +++ b/src/web/auto-reply/monitor/broadcast.ts @@ -54,11 +54,13 @@ export async function maybeBroadcastMessage(params: { sessionKey: buildAgentSessionKey({ agentId: normalizedAgentId, channel: "whatsapp", + accountId: params.route.accountId, peer: { kind: params.msg.chatType === "group" ? "group" : "dm", id: params.peerId, }, dmScope: params.cfg.session?.dmScope, + identityLinks: params.cfg.session?.identityLinks, }), mainSessionKey: buildAgentMainSessionKey({ agentId: normalizedAgentId, From 6044bf36374be8ee52374e2cbb71ec188ee9b9a3 Mon Sep 17 00:00:00 2001 From: Shadow Date: Wed, 28 Jan 2026 00:36:12 -0600 Subject: [PATCH 23/58] Discord: fix resolveDiscordTarget parse options --- CHANGELOG.md | 1 + src/discord/send.shared.ts | 21 ++++++++++++++------- src/discord/targets.ts | 3 ++- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17f957f07..5909c9899 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,7 @@ Status: beta. - Web UI: auto-expand the chat compose textarea while typing (with sensible max height). (#2950) Thanks @shivamraut101. - Gateway: prevent crashes on transient network errors (fetch failures, timeouts, DNS). Added fatal error detection to only exit on truly critical errors. Fixes #2895, #2879, #2873. (#2980) Thanks @elliotsecops. - Agents: guard channel tool listActions to avoid plugin crashes. (#2859) Thanks @mbelinky. +- Discord: stop resolveDiscordTarget from passing directory params into messaging target parsers. Fixes #3167. Thanks @thewilloftheshadow. - Discord: avoid resolving bare channel names to user DMs when a username matches. Thanks @thewilloftheshadow. - Discord: fix directory config type import for target resolution. Thanks @thewilloftheshadow. - Providers: update MiniMax API endpoint and compatibility mode. (#3064) Thanks @hlbbbbbbb. diff --git a/src/discord/send.shared.ts b/src/discord/send.shared.ts index 1cf2a93a9..e247300ee 100644 --- a/src/discord/send.shared.ts +++ b/src/discord/send.shared.ts @@ -118,19 +118,26 @@ export async function parseAndResolveRecipient( const accountInfo = resolveDiscordAccount({ cfg, accountId }); // First try to resolve using directory lookup (handles usernames) - const resolved = await resolveDiscordTarget(raw, { - cfg, - accountId: accountInfo.accountId, - }); + const trimmed = raw.trim(); + const parseOptions = { + ambiguousMessage: `Ambiguous Discord recipient "${trimmed}". Use "user:${trimmed}" for DMs or "channel:${trimmed}" for channel messages.`, + }; + + const resolved = await resolveDiscordTarget( + raw, + { + cfg, + accountId: accountInfo.accountId, + }, + parseOptions, + ); if (resolved) { return { kind: resolved.kind, id: resolved.id }; } // Fallback to standard parsing (for channels, etc.) - const parsed = parseDiscordTarget(raw, { - ambiguousMessage: `Ambiguous Discord recipient "${raw.trim()}". Use "user:${raw.trim()}" for DMs or "channel:${raw.trim()}" for channel messages.`, - }); + const parsed = parseDiscordTarget(raw, parseOptions); if (!parsed) { throw new Error("Recipient is required for Discord sends"); diff --git a/src/discord/targets.ts b/src/discord/targets.ts index c6f56cf53..5ea6f5b1b 100644 --- a/src/discord/targets.ts +++ b/src/discord/targets.ts @@ -71,16 +71,17 @@ export function resolveDiscordChannelId(raw: string): string { * * @param raw - The username or raw target string (e.g., "john.doe") * @param options - Directory configuration params (cfg, accountId, limit) + * @param parseOptions - Messaging target parsing options (defaults, ambiguity message) * @returns Parsed MessagingTarget with user ID, or undefined if not found */ export async function resolveDiscordTarget( raw: string, options: DirectoryConfigParams, + parseOptions: DiscordTargetParseOptions = {}, ): Promise { const trimmed = raw.trim(); if (!trimmed) return undefined; - const parseOptions: DiscordTargetParseOptions = {}; const likelyUsername = isLikelyUsername(trimmed); const shouldLookup = isExplicitUserLookup(trimmed, parseOptions) || likelyUsername; const directParse = safeParseDiscordTarget(trimmed, parseOptions); From 9688454a30e618e878ca795fbe46da58b2e2e9d3 Mon Sep 17 00:00:00 2001 From: Shadow Date: Wed, 28 Jan 2026 01:12:04 -0600 Subject: [PATCH 24/58] Accidental inclusion --- skills/bitwarden/SKILL.md | 101 -------------------- skills/bitwarden/references/templates.md | 116 ----------------------- skills/bitwarden/scripts/bw-session.sh | 33 ------- 3 files changed, 250 deletions(-) delete mode 100644 skills/bitwarden/SKILL.md delete mode 100644 skills/bitwarden/references/templates.md delete mode 100755 skills/bitwarden/scripts/bw-session.sh diff --git a/skills/bitwarden/SKILL.md b/skills/bitwarden/SKILL.md deleted file mode 100644 index 3e384597a..000000000 --- a/skills/bitwarden/SKILL.md +++ /dev/null @@ -1,101 +0,0 @@ ---- -name: bitwarden -description: Manage passwords and credentials via Bitwarden CLI (bw). Use for storing, retrieving, creating, or updating logins, credit cards, secure notes, and identities. Trigger when automating authentication, filling payment forms, or managing secrets programmatically. ---- - -# Bitwarden CLI - -Full read/write vault access via `bw` command. - -## Prerequisites - -```bash -brew install bitwarden-cli -bw login # one-time, prompts for master password -``` - -## Session Management - -Bitwarden requires an unlocked session. Use the helper script: - -```bash -source scripts/bw-session.sh -# Sets BW_SESSION env var -``` - -Or manually: -```bash -export BW_SESSION=$(echo '' | bw unlock --raw) -bw sync # always sync after unlock -``` - -## Common Operations - -### Retrieve credentials -```bash -bw get password "Site Name" -bw get username "Site Name" -bw get item "Site Name" --pretty | jq '.login' -``` - -### Create login -```bash -bw get template item | jq ' - .type = 1 | - .name = "Site Name" | - .login.username = "user@email.com" | - .login.password = "secret123" | - .login.uris = [{uri: "https://example.com"}] -' | bw encode | bw create item -``` - -### Create credit card -```bash -bw get template item | jq ' - .type = 3 | - .name = "Card Name" | - .card.cardholderName = "John Doe" | - .card.brand = "Visa" | - .card.number = "4111111111111111" | - .card.expMonth = "12" | - .card.expYear = "2030" | - .card.code = "123" -' | bw encode | bw create item -``` - -### Get card for payment automation -```bash -bw get item "Card Name" | jq -r '.card | "\(.number) \(.expMonth)/\(.expYear) \(.code)"' -``` - -### List items -```bash -bw list items | jq -r '.[] | "\(.type)|\(.name)"' -# Types: 1=login, 2=note, 3=card, 4=identity -``` - -### Search -```bash -bw list items --search "vilaviniteca" | jq '.[0]' -``` - -## Item Types - -| Type | Value | Use | -|------|-------|-----| -| Login | 1 | Website credentials | -| Secure Note | 2 | Freeform text | -| Card | 3 | Credit/debit cards | -| Identity | 4 | Personal info | - -## References - -- [templates.md](references/templates.md) — Full jq templates for all item types -- [Bitwarden CLI docs](https://bitwarden.com/help/cli/) - -## Tips - -1. **Always sync** after creating/editing items: `bw sync` -2. **Session expires** — re-unlock if you get auth errors -3. **Delete sensitive messages** after receiving credentials -4. **Card numbers** may not import from other managers (security restriction) diff --git a/skills/bitwarden/references/templates.md b/skills/bitwarden/references/templates.md deleted file mode 100644 index a14e011e4..000000000 --- a/skills/bitwarden/references/templates.md +++ /dev/null @@ -1,116 +0,0 @@ -# Bitwarden Item Templates - -jq patterns for creating vault items via CLI. - -## Login (type=1) - -```bash -bw get template item | jq ' - .type = 1 | - .name = "Example Site" | - .notes = "Optional notes" | - .favorite = false | - .login.username = "user@example.com" | - .login.password = "secretPassword123" | - .login.totp = "otpauth://totp/..." | - .login.uris = [ - {uri: "https://example.com", match: null}, - {uri: "https://app.example.com", match: null} - ] -' | bw encode | bw create item -``` - -## Credit Card (type=3) - -```bash -bw get template item | jq ' - .type = 3 | - .name = "Visa ending 1234" | - .notes = "Primary card" | - .card.cardholderName = "JOHN DOE" | - .card.brand = "Visa" | - .card.number = "4111111111111111" | - .card.expMonth = "12" | - .card.expYear = "2030" | - .card.code = "123" -' | bw encode | bw create item -``` - -**Brands:** Visa, Mastercard, Amex, Discover, Diners Club, JCB, Maestro, UnionPay, Other - -## Secure Note (type=2) - -```bash -bw get template item | jq ' - .type = 2 | - .name = "API Keys" | - .notes = "OPENAI_KEY=sk-xxx\nANTHROPIC_KEY=sk-ant-xxx" | - .secureNote.type = 0 -' | bw encode | bw create item -``` - -## Identity (type=4) - -```bash -bw get template item | jq ' - .type = 4 | - .name = "Personal Info" | - .identity.title = "Mr" | - .identity.firstName = "John" | - .identity.lastName = "Doe" | - .identity.email = "john@example.com" | - .identity.phone = "+34612345678" | - .identity.address1 = "123 Main St" | - .identity.city = "Barcelona" | - .identity.state = "Catalunya" | - .identity.postalCode = "08001" | - .identity.country = "ES" -' | bw encode | bw create item -``` - -## Edit Existing Item - -```bash -# Get item, modify, update -bw get item | jq '.login.password = "newPassword"' | bw encode | bw edit item -``` - -## Custom Fields - -```bash -bw get template item | jq ' - .type = 1 | - .name = "With Custom Fields" | - .fields = [ - {name: "Security Question", value: "Pet name", type: 0}, - {name: "PIN", value: "1234", type: 1} - ] -' | bw encode | bw create item -``` - -**Field types:** 0=text, 1=hidden, 2=boolean - -## Retrieve Patterns - -```bash -# Password only -bw get password "Site Name" - -# Username only -bw get username "Site Name" - -# Full login object -bw get item "Site Name" | jq '.login' - -# Card number -bw get item "Card Name" | jq -r '.card.number' - -# All card fields for form filling -bw get item "Card Name" | jq -r '.card | [.number, .expMonth, .expYear, .code] | @tsv' - -# Search by URL -bw list items --url "example.com" | jq '.[0].login' - -# List all cards -bw list items | jq '.[] | select(.type == 3) | .name' -``` diff --git a/skills/bitwarden/scripts/bw-session.sh b/skills/bitwarden/scripts/bw-session.sh deleted file mode 100755 index 1b353583e..000000000 --- a/skills/bitwarden/scripts/bw-session.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/bin/bash -# Unlock Bitwarden vault and export session key -# Usage: source bw-session.sh -# Or: source bw-session.sh (prompts for password) - -set -e - -if [ -n "$1" ]; then - MASTER_PW="$1" -else - read -sp "Bitwarden master password: " MASTER_PW - echo -fi - -# Check if already logged in -if ! bw login --check &>/dev/null; then - echo "Not logged in. Run: bw login " - return 1 -fi - -# Unlock and get session -export BW_SESSION=$(echo "$MASTER_PW" | bw unlock --raw 2>/dev/null) - -if [ -z "$BW_SESSION" ]; then - echo "Failed to unlock vault" - return 1 -fi - -# Sync to get latest -bw sync &>/dev/null - -echo "✓ Vault unlocked and synced" -echo "Session valid for this shell" From 39b7f9d5817e58263330d39cbf65cb182efe1259 Mon Sep 17 00:00:00 2001 From: Roopak Nijhara Date: Tue, 27 Jan 2026 16:54:08 +0530 Subject: [PATCH 25/58] feat(hooks): make session-memory message count configurable (#2681) Adds `messages` config option to session-memory hook (default: 15). Fixes filter order bug - now filters user/assistant messages first, then slices to get exactly N messages. Previously sliced first which could result in fewer messages when non-message entries were present. Co-Authored-By: Claude Opus 4.5 --- src/hooks/bundled/session-memory/HOOK.md | 27 +- .../bundled/session-memory/handler.test.ts | 379 ++++++++++++++++++ src/hooks/bundled/session-memory/handler.ts | 30 +- 3 files changed, 424 insertions(+), 12 deletions(-) create mode 100644 src/hooks/bundled/session-memory/handler.test.ts diff --git a/src/hooks/bundled/session-memory/HOOK.md b/src/hooks/bundled/session-memory/HOOK.md index 2a635a645..0875486c9 100644 --- a/src/hooks/bundled/session-memory/HOOK.md +++ b/src/hooks/bundled/session-memory/HOOK.md @@ -23,7 +23,7 @@ Automatically saves session context to your workspace memory when you issue the When you run `/new` to start a fresh session: 1. **Finds the previous session** - Uses the pre-reset session entry to locate the correct transcript -2. **Extracts conversation** - Reads the last 15 lines of conversation from the session +2. **Extracts conversation** - Reads the last N user/assistant messages from the session (default: 15, configurable) 3. **Generates descriptive slug** - Uses LLM to create a meaningful filename slug based on conversation content 4. **Saves to memory** - Creates a new file at `/memory/YYYY-MM-DD-slug.md` 5. **Sends confirmation** - Notifies you with the file path @@ -57,7 +57,30 @@ The hook uses your configured LLM provider to generate slugs, so it works with a ## Configuration -No additional configuration required. The hook automatically: +The hook supports optional configuration: + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `messages` | number | 15 | Number of user/assistant messages to include in the memory file | + +Example configuration: + +```json +{ + "hooks": { + "internal": { + "entries": { + "session-memory": { + "enabled": true, + "messages": 25 + } + } + } + } +} +``` + +The hook automatically: - Uses your workspace directory (`~/clawd` by default) - Uses your configured LLM for slug generation diff --git a/src/hooks/bundled/session-memory/handler.test.ts b/src/hooks/bundled/session-memory/handler.test.ts new file mode 100644 index 000000000..525e21059 --- /dev/null +++ b/src/hooks/bundled/session-memory/handler.test.ts @@ -0,0 +1,379 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import handler from "./handler.js"; +import { createHookEvent } from "../../hooks.js"; +import type { ClawdbotConfig } from "../../../config/config.js"; +import { makeTempWorkspace, writeWorkspaceFile } from "../../../test-helpers/workspace.js"; + +/** + * Create a mock session JSONL file with various entry types + */ +function createMockSessionContent( + entries: Array<{ role: string; content: string } | { type: string }>, +): string { + return entries + .map((entry) => { + if ("role" in entry) { + return JSON.stringify({ + type: "message", + message: { + role: entry.role, + content: entry.content, + }, + }); + } + // Non-message entry (tool call, system, etc.) + return JSON.stringify(entry); + }) + .join("\n"); +} + +describe("session-memory hook", () => { + it("skips non-command events", async () => { + const tempDir = await makeTempWorkspace("clawdbot-session-memory-"); + + const event = createHookEvent("agent", "bootstrap", "agent:main:main", { + workspaceDir: tempDir, + }); + + await handler(event); + + // Memory directory should not be created for non-command events + const memoryDir = path.join(tempDir, "memory"); + await expect(fs.access(memoryDir)).rejects.toThrow(); + }); + + it("skips commands other than new", async () => { + const tempDir = await makeTempWorkspace("clawdbot-session-memory-"); + + const event = createHookEvent("command", "help", "agent:main:main", { + workspaceDir: tempDir, + }); + + await handler(event); + + // Memory directory should not be created for other commands + const memoryDir = path.join(tempDir, "memory"); + await expect(fs.access(memoryDir)).rejects.toThrow(); + }); + + it("creates memory file with session content on /new command", async () => { + const tempDir = await makeTempWorkspace("clawdbot-session-memory-"); + const sessionsDir = path.join(tempDir, "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + + // Create a mock session file with user/assistant messages + const sessionContent = createMockSessionContent([ + { role: "user", content: "Hello there" }, + { role: "assistant", content: "Hi! How can I help?" }, + { role: "user", content: "What is 2+2?" }, + { role: "assistant", content: "2+2 equals 4" }, + ]); + const sessionFile = await writeWorkspaceFile({ + dir: sessionsDir, + name: "test-session.jsonl", + content: sessionContent, + }); + + const cfg: ClawdbotConfig = { + agents: { defaults: { workspace: tempDir } }, + }; + + const event = createHookEvent("command", "new", "agent:main:main", { + cfg, + previousSessionEntry: { + sessionId: "test-123", + sessionFile, + }, + }); + + await handler(event); + + // Memory file should be created + const memoryDir = path.join(tempDir, "memory"); + const files = await fs.readdir(memoryDir); + expect(files.length).toBe(1); + + // Read the memory file and verify content + const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8"); + expect(memoryContent).toContain("user: Hello there"); + expect(memoryContent).toContain("assistant: Hi! How can I help?"); + expect(memoryContent).toContain("user: What is 2+2?"); + expect(memoryContent).toContain("assistant: 2+2 equals 4"); + }); + + it("filters out non-message entries (tool calls, system)", async () => { + const tempDir = await makeTempWorkspace("clawdbot-session-memory-"); + const sessionsDir = path.join(tempDir, "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + + // Create session with mixed entry types + const sessionContent = createMockSessionContent([ + { role: "user", content: "Hello" }, + { type: "tool_use", tool: "search", input: "test" }, + { role: "assistant", content: "World" }, + { type: "tool_result", result: "found it" }, + { role: "user", content: "Thanks" }, + ]); + const sessionFile = await writeWorkspaceFile({ + dir: sessionsDir, + name: "test-session.jsonl", + content: sessionContent, + }); + + const cfg: ClawdbotConfig = { + agents: { defaults: { workspace: tempDir } }, + }; + + const event = createHookEvent("command", "new", "agent:main:main", { + cfg, + previousSessionEntry: { + sessionId: "test-123", + sessionFile, + }, + }); + + await handler(event); + + const memoryDir = path.join(tempDir, "memory"); + const files = await fs.readdir(memoryDir); + const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8"); + + // Only user/assistant messages should be present + expect(memoryContent).toContain("user: Hello"); + expect(memoryContent).toContain("assistant: World"); + expect(memoryContent).toContain("user: Thanks"); + // Tool entries should not appear + expect(memoryContent).not.toContain("tool_use"); + expect(memoryContent).not.toContain("tool_result"); + expect(memoryContent).not.toContain("search"); + }); + + it("filters out command messages starting with /", async () => { + const tempDir = await makeTempWorkspace("clawdbot-session-memory-"); + const sessionsDir = path.join(tempDir, "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + + const sessionContent = createMockSessionContent([ + { role: "user", content: "/help" }, + { role: "assistant", content: "Here is help info" }, + { role: "user", content: "Normal message" }, + { role: "user", content: "/new" }, + ]); + const sessionFile = await writeWorkspaceFile({ + dir: sessionsDir, + name: "test-session.jsonl", + content: sessionContent, + }); + + const cfg: ClawdbotConfig = { + agents: { defaults: { workspace: tempDir } }, + }; + + const event = createHookEvent("command", "new", "agent:main:main", { + cfg, + previousSessionEntry: { + sessionId: "test-123", + sessionFile, + }, + }); + + await handler(event); + + const memoryDir = path.join(tempDir, "memory"); + const files = await fs.readdir(memoryDir); + const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8"); + + // Command messages should be filtered out + expect(memoryContent).not.toContain("/help"); + expect(memoryContent).not.toContain("/new"); + // Normal messages should be present + expect(memoryContent).toContain("assistant: Here is help info"); + expect(memoryContent).toContain("user: Normal message"); + }); + + it("respects custom messages config (limits to N messages)", async () => { + const tempDir = await makeTempWorkspace("clawdbot-session-memory-"); + const sessionsDir = path.join(tempDir, "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + + // Create 10 messages + const entries = []; + for (let i = 1; i <= 10; i++) { + entries.push({ role: "user", content: `Message ${i}` }); + } + const sessionContent = createMockSessionContent(entries); + const sessionFile = await writeWorkspaceFile({ + dir: sessionsDir, + name: "test-session.jsonl", + content: sessionContent, + }); + + // Configure to only include last 3 messages + const cfg: ClawdbotConfig = { + agents: { defaults: { workspace: tempDir } }, + hooks: { + internal: { + entries: { + "session-memory": { enabled: true, messages: 3 }, + }, + }, + }, + }; + + const event = createHookEvent("command", "new", "agent:main:main", { + cfg, + previousSessionEntry: { + sessionId: "test-123", + sessionFile, + }, + }); + + await handler(event); + + const memoryDir = path.join(tempDir, "memory"); + const files = await fs.readdir(memoryDir); + const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8"); + + // Only last 3 messages should be present + expect(memoryContent).not.toContain("user: Message 1\n"); + expect(memoryContent).not.toContain("user: Message 7\n"); + expect(memoryContent).toContain("user: Message 8"); + expect(memoryContent).toContain("user: Message 9"); + expect(memoryContent).toContain("user: Message 10"); + }); + + it("filters messages before slicing (fix for #2681)", async () => { + const tempDir = await makeTempWorkspace("clawdbot-session-memory-"); + const sessionsDir = path.join(tempDir, "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + + // Create session with many tool entries interspersed with messages + // This tests that we filter FIRST, then slice - not the other way around + const entries = [ + { role: "user", content: "First message" }, + { type: "tool_use", tool: "test1" }, + { type: "tool_result", result: "result1" }, + { role: "assistant", content: "Second message" }, + { type: "tool_use", tool: "test2" }, + { type: "tool_result", result: "result2" }, + { role: "user", content: "Third message" }, + { type: "tool_use", tool: "test3" }, + { type: "tool_result", result: "result3" }, + { role: "assistant", content: "Fourth message" }, + ]; + const sessionContent = createMockSessionContent(entries); + const sessionFile = await writeWorkspaceFile({ + dir: sessionsDir, + name: "test-session.jsonl", + content: sessionContent, + }); + + // Request 3 messages - if we sliced first, we'd only get 1-2 messages + // because the last 3 lines include tool entries + const cfg: ClawdbotConfig = { + agents: { defaults: { workspace: tempDir } }, + hooks: { + internal: { + entries: { + "session-memory": { enabled: true, messages: 3 }, + }, + }, + }, + }; + + const event = createHookEvent("command", "new", "agent:main:main", { + cfg, + previousSessionEntry: { + sessionId: "test-123", + sessionFile, + }, + }); + + await handler(event); + + const memoryDir = path.join(tempDir, "memory"); + const files = await fs.readdir(memoryDir); + const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8"); + + // Should have exactly 3 user/assistant messages (the last 3) + expect(memoryContent).not.toContain("First message"); + expect(memoryContent).toContain("user: Third message"); + expect(memoryContent).toContain("assistant: Second message"); + expect(memoryContent).toContain("assistant: Fourth message"); + }); + + it("handles empty session files gracefully", async () => { + const tempDir = await makeTempWorkspace("clawdbot-session-memory-"); + const sessionsDir = path.join(tempDir, "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + + const sessionFile = await writeWorkspaceFile({ + dir: sessionsDir, + name: "test-session.jsonl", + content: "", + }); + + const cfg: ClawdbotConfig = { + agents: { defaults: { workspace: tempDir } }, + }; + + const event = createHookEvent("command", "new", "agent:main:main", { + cfg, + previousSessionEntry: { + sessionId: "test-123", + sessionFile, + }, + }); + + // Should not throw + await handler(event); + + // Memory file should still be created with metadata + const memoryDir = path.join(tempDir, "memory"); + const files = await fs.readdir(memoryDir); + expect(files.length).toBe(1); + }); + + it("handles session files with fewer messages than requested", async () => { + const tempDir = await makeTempWorkspace("clawdbot-session-memory-"); + const sessionsDir = path.join(tempDir, "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + + // Only 2 messages but requesting 15 (default) + const sessionContent = createMockSessionContent([ + { role: "user", content: "Only message 1" }, + { role: "assistant", content: "Only message 2" }, + ]); + const sessionFile = await writeWorkspaceFile({ + dir: sessionsDir, + name: "test-session.jsonl", + content: sessionContent, + }); + + const cfg: ClawdbotConfig = { + agents: { defaults: { workspace: tempDir } }, + }; + + const event = createHookEvent("command", "new", "agent:main:main", { + cfg, + previousSessionEntry: { + sessionId: "test-123", + sessionFile, + }, + }); + + await handler(event); + + const memoryDir = path.join(tempDir, "memory"); + const files = await fs.readdir(memoryDir); + const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8"); + + // Both messages should be included + expect(memoryContent).toContain("user: Only message 1"); + expect(memoryContent).toContain("assistant: Only message 2"); + }); +}); diff --git a/src/hooks/bundled/session-memory/handler.ts b/src/hooks/bundled/session-memory/handler.ts index c087d73e8..c38a46e7b 100644 --- a/src/hooks/bundled/session-memory/handler.ts +++ b/src/hooks/bundled/session-memory/handler.ts @@ -11,22 +11,23 @@ import os from "node:os"; import type { MoltbotConfig } from "../../../config/config.js"; import { resolveAgentWorkspaceDir } from "../../../agents/agent-scope.js"; import { resolveAgentIdFromSessionKey } from "../../../routing/session-key.js"; +import { resolveHookConfig } from "../../config.js"; import type { HookHandler } from "../../hooks.js"; /** * Read recent messages from session file for slug generation */ -async function getRecentSessionContent(sessionFilePath: string): Promise { +async function getRecentSessionContent( + sessionFilePath: string, + messageCount: number = 15, +): Promise { try { const content = await fs.readFile(sessionFilePath, "utf-8"); const lines = content.trim().split("\n"); - // Get last 15 lines (recent conversation) - const recentLines = lines.slice(-15); - - // Parse JSONL and extract messages - const messages: string[] = []; - for (const line of recentLines) { + // Parse JSONL and extract user/assistant messages first + const allMessages: string[] = []; + for (const line of lines) { try { const entry = JSON.parse(line); // Session files have entries with type="message" containing a nested message object @@ -39,7 +40,7 @@ async function getRecentSessionContent(sessionFilePath: string): Promise c.type === "text")?.text : msg.content; if (text && !text.startsWith("/")) { - messages.push(`${role}: ${text}`); + allMessages.push(`${role}: ${text}`); } } } @@ -48,7 +49,9 @@ async function getRecentSessionContent(sessionFilePath: string): Promise { const sessionFile = currentSessionFile || undefined; + // Read message count from hook config (default: 15) + const hookConfig = resolveHookConfig(cfg, "session-memory"); + const messageCount = + typeof hookConfig?.messages === "number" && hookConfig.messages > 0 + ? hookConfig.messages + : 15; + let slug: string | null = null; let sessionContent: string | null = null; if (sessionFile) { // Get recent conversation content - sessionContent = await getRecentSessionContent(sessionFile); + sessionContent = await getRecentSessionContent(sessionFile, messageCount); console.log("[session-memory] sessionContent length:", sessionContent?.length || 0); if (sessionContent && cfg) { From bffcef981da30200542eef8e4e3e8736c728cc60 Mon Sep 17 00:00:00 2001 From: Roopak Nijhara Date: Tue, 27 Jan 2026 21:30:44 +0530 Subject: [PATCH 26/58] style: run pnpm format --- src/hooks/bundled/session-memory/HOOK.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hooks/bundled/session-memory/HOOK.md b/src/hooks/bundled/session-memory/HOOK.md index 0875486c9..41223eb05 100644 --- a/src/hooks/bundled/session-memory/HOOK.md +++ b/src/hooks/bundled/session-memory/HOOK.md @@ -59,9 +59,9 @@ The hook uses your configured LLM provider to generate slugs, so it works with a The hook supports optional configuration: -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `messages` | number | 15 | Number of user/assistant messages to include in the memory file | +| Option | Type | Default | Description | +| ---------- | ------ | ------- | --------------------------------------------------------------- | +| `messages` | number | 15 | Number of user/assistant messages to include in the memory file | Example configuration: From d93f8ffc13b67f6ea065fdfece1ea311c6a6ddbc Mon Sep 17 00:00:00 2001 From: Roopak Nijhara Date: Tue, 27 Jan 2026 22:52:04 +0530 Subject: [PATCH 27/58] fix: use fileURLToPath for Windows compatibility --- src/hooks/bundled/session-memory/handler.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/hooks/bundled/session-memory/handler.ts b/src/hooks/bundled/session-memory/handler.ts index c38a46e7b..5b5a69c9c 100644 --- a/src/hooks/bundled/session-memory/handler.ts +++ b/src/hooks/bundled/session-memory/handler.ts @@ -8,6 +8,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import os from "node:os"; +import { fileURLToPath } from "node:url"; import type { MoltbotConfig } from "../../../config/config.js"; import { resolveAgentWorkspaceDir } from "../../../agents/agent-scope.js"; import { resolveAgentIdFromSessionKey } from "../../../routing/session-key.js"; @@ -116,10 +117,7 @@ const saveSessionToMemory: HookHandler = async (event) => { // Dynamically import the LLM slug generator (avoids module caching issues) // When compiled, handler is at dist/hooks/bundled/session-memory/handler.js // Going up ../.. puts us at dist/hooks/, so just add llm-slug-generator.js - const moltbotRoot = path.resolve( - path.dirname(import.meta.url.replace("file://", "")), - "../..", - ); + const moltbotRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const slugGenPath = path.join(moltbotRoot, "llm-slug-generator.js"); const { generateSlugViaLLM } = await import(slugGenPath); From 57efd8e0838c7016d1c8e3036c764345e646b380 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Wed, 28 Jan 2026 13:17:50 +0100 Subject: [PATCH 28/58] fix(media): add missing MIME type mappings for audio/video files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add mappings for audio/x-m4a, audio/mp4, and video/quicktime to ensure media files sent as documents are saved with proper extensions, enabling automatic transcription/analysis tools to work correctly. - audio/x-m4a → .m4a - audio/mp4 → .m4a - video/quicktime → .mov Also adds comprehensive test coverage for extensionForMime(). --- src/media/mime.test.ts | 46 +++++++++++++++++++++++++++++++++++++++++- src/media/mime.ts | 3 +++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/media/mime.test.ts b/src/media/mime.test.ts index a3c2a35d8..92325a62e 100644 --- a/src/media/mime.test.ts +++ b/src/media/mime.test.ts @@ -1,7 +1,7 @@ import JSZip from "jszip"; import { describe, expect, it } from "vitest"; -import { detectMime, imageMimeFromFormat } from "./mime.js"; +import { detectMime, extensionForMime, imageMimeFromFormat } from "./mime.js"; async function makeOoxmlZip(opts: { mainMime: string; partPath: string }): Promise { const zip = new JSZip(); @@ -53,3 +53,47 @@ describe("mime detection", () => { expect(mime).toBe("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); }); }); + +describe("extensionForMime", () => { + it("maps image MIME types to extensions", () => { + expect(extensionForMime("image/jpeg")).toBe(".jpg"); + expect(extensionForMime("image/png")).toBe(".png"); + expect(extensionForMime("image/webp")).toBe(".webp"); + expect(extensionForMime("image/gif")).toBe(".gif"); + expect(extensionForMime("image/heic")).toBe(".heic"); + }); + + it("maps audio MIME types to extensions", () => { + expect(extensionForMime("audio/mpeg")).toBe(".mp3"); + expect(extensionForMime("audio/ogg")).toBe(".ogg"); + expect(extensionForMime("audio/x-m4a")).toBe(".m4a"); + expect(extensionForMime("audio/mp4")).toBe(".m4a"); + }); + + it("maps video MIME types to extensions", () => { + expect(extensionForMime("video/mp4")).toBe(".mp4"); + expect(extensionForMime("video/quicktime")).toBe(".mov"); + }); + + it("maps document MIME types to extensions", () => { + expect(extensionForMime("application/pdf")).toBe(".pdf"); + expect(extensionForMime("text/plain")).toBe(".txt"); + expect(extensionForMime("text/markdown")).toBe(".md"); + }); + + it("handles case insensitivity", () => { + expect(extensionForMime("IMAGE/JPEG")).toBe(".jpg"); + expect(extensionForMime("Audio/X-M4A")).toBe(".m4a"); + expect(extensionForMime("Video/QuickTime")).toBe(".mov"); + }); + + it("returns undefined for unknown MIME types", () => { + expect(extensionForMime("video/unknown")).toBeUndefined(); + expect(extensionForMime("application/x-custom")).toBeUndefined(); + }); + + it("returns undefined for null or undefined input", () => { + expect(extensionForMime(null)).toBeUndefined(); + expect(extensionForMime(undefined)).toBeUndefined(); + }); +}); diff --git a/src/media/mime.ts b/src/media/mime.ts index 79677b1cb..c50e9152c 100644 --- a/src/media/mime.ts +++ b/src/media/mime.ts @@ -13,7 +13,10 @@ const EXT_BY_MIME: Record = { "image/gif": ".gif", "audio/ogg": ".ogg", "audio/mpeg": ".mp3", + "audio/x-m4a": ".m4a", + "audio/mp4": ".m4a", "video/mp4": ".mp4", + "video/quicktime": ".mov", "application/pdf": ".pdf", "application/json": ".json", "application/zip": ".zip", From 01e0d3a320252664dc2bdeafdacb96cb4a473be0 Mon Sep 17 00:00:00 2001 From: Akshay Date: Wed, 28 Jan 2026 21:26:25 +0800 Subject: [PATCH 29/58] fix(cli): initialize plugins before pairing CLI registration (#3272) The pairing CLI calls listPairingChannels() at registration time, which requires the plugin registry to be populated. Without this, plugin-provided channels like Matrix fail with "does not support pairing" even though they have pairing adapters defined. This mirrors the existing pattern used by the plugins CLI entry. Co-authored-by: Shakker <165377636+shakkernerd@users.noreply.github.com> --- src/cli/program/register.subclis.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts index 97ca4508a..e5684fbea 100644 --- a/src/cli/program/register.subclis.ts +++ b/src/cli/program/register.subclis.ts @@ -168,6 +168,11 @@ const entries: SubCliEntry[] = [ name: "pairing", description: "Pairing helpers", register: async (program) => { + // Initialize plugins before registering pairing CLI. + // The pairing CLI calls listPairingChannels() at registration time, + // which requires the plugin registry to be populated with channel plugins. + const { registerPluginCliCommands } = await import("../../plugins/cli.js"); + registerPluginCliCommands(program, await loadConfig()); const mod = await import("../pairing-cli.js"); mod.registerPairingCli(program); }, From 109ac1c54932511b36dc51fb0d18fbcddd7766d1 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 28 Jan 2026 11:39:35 -0500 Subject: [PATCH 30/58] fix: banner spacing --- src/cli/banner.ts | 1 + src/commands/onboard-helpers.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cli/banner.ts b/src/cli/banner.ts index 6ca7d4cbc..e19433e11 100644 --- a/src/cli/banner.ts +++ b/src/cli/banner.ts @@ -71,6 +71,7 @@ const LOBSTER_ASCII = [ "██░███░██░▀▀▀░██░▀▀░███░████░▀▀░██░▀▀▀░███░████", "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀", " 🦞 FRESH DAILY 🦞 ", + " ", ]; export function formatCliBannerArt(options: BannerOptions = {}): string { diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index 165365bb6..376555a39 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -69,7 +69,8 @@ export function printWizardHeader(runtime: RuntimeEnv) { "██░█░█░██░███░██░██████░████░▄▄▀██░███░███░████", "██░███░██░▀▀▀░██░▀▀░███░████░▀▀░██░▀▀▀░███░████", "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀", - " 🦞 FRESH DAILY 🦞 ", + " 🦞 FRESH DAILY 🦞 ", + " ", ].join("\n"); runtime.log(header); } From a7534dc22382c42465f3676724536a014ce0cbf7 Mon Sep 17 00:00:00 2001 From: Tyler Yust <64381258+tyler6204@users.noreply.github.com> Date: Wed, 28 Jan 2026 13:32:10 -0800 Subject: [PATCH 31/58] fix(ui): gateway URL confirmation modal (based on #2880) (#3578) * fix: adding confirmation modal to confirm gateway url change * refactor: added modal instead of confirm prompt * fix(ui): reconnect after confirming gateway url (#2880) (thanks @0xacb) --------- Co-authored-by: 0xacb --- ui/src/ui/app-render.ts | 2 ++ ui/src/ui/app-settings.ts | 3 +- ui/src/ui/app-view-state.ts | 3 ++ ui/src/ui/app.ts | 16 +++++++++ ui/src/ui/views/gateway-url-confirmation.ts | 39 +++++++++++++++++++++ 5 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 ui/src/ui/views/gateway-url-confirmation.ts diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index a088c33ff..422af6863 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -42,6 +42,7 @@ import { renderNodes } from "./views/nodes"; import { renderOverview } from "./views/overview"; import { renderSessions } from "./views/sessions"; import { renderExecApprovalPrompt } from "./views/exec-approval"; +import { renderGatewayUrlConfirmation } from "./views/gateway-url-confirmation"; import { approveDevicePairing, loadDevices, @@ -578,6 +579,7 @@ export function renderApp(state: AppViewState) { : nothing} ${renderExecApprovalPrompt(state)} + ${renderGatewayUrlConfirmation(state)} `; } diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index e269742b2..7e3ab29cf 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -33,6 +33,7 @@ type SettingsHost = { basePath: string; themeMedia: MediaQueryList | null; themeMediaHandler: ((event: MediaQueryListEvent) => void) | null; + pendingGatewayUrl?: string | null; }; export function applySettings(host: SettingsHost, next: UiSettings) { @@ -98,7 +99,7 @@ export function applySettingsFromUrl(host: SettingsHost) { if (gatewayUrlRaw != null) { const gatewayUrl = gatewayUrlRaw.trim(); if (gatewayUrl && gatewayUrl !== host.settings.gatewayUrl) { - applySettings(host, { ...host.settings, gatewayUrl }); + host.pendingGatewayUrl = gatewayUrl; } params.delete("gatewayUrl"); shouldCleanUrl = true; diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 069465e32..f58656bfb 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -73,6 +73,7 @@ export type AppViewState = { execApprovalQueue: ExecApprovalRequest[]; execApprovalBusy: boolean; execApprovalError: string | null; + pendingGatewayUrl: string | null; configLoading: boolean; configRaw: string; configRawOriginal: string; @@ -165,6 +166,8 @@ export type AppViewState = { handleNostrProfileImport: () => Promise; handleNostrProfileToggleAdvanced: () => void; handleExecApprovalDecision: (decision: "allow-once" | "allow-always" | "deny") => Promise; + handleGatewayUrlConfirm: () => void; + handleGatewayUrlCancel: () => void; handleConfigLoad: () => Promise; handleConfigSave: () => Promise; handleConfigApply: () => Promise; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index d23e543cd..26f4a5836 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -152,6 +152,7 @@ export class MoltbotApp extends LitElement { @state() execApprovalQueue: ExecApprovalRequest[] = []; @state() execApprovalBusy = false; @state() execApprovalError: string | null = null; + @state() pendingGatewayUrl: string | null = null; @state() configLoading = false; @state() configRaw = "{\n}\n"; @@ -448,6 +449,21 @@ export class MoltbotApp extends LitElement { } } + handleGatewayUrlConfirm() { + const nextGatewayUrl = this.pendingGatewayUrl; + if (!nextGatewayUrl) return; + this.pendingGatewayUrl = null; + applySettingsInternal( + this as unknown as Parameters[0], + { ...this.settings, gatewayUrl: nextGatewayUrl }, + ); + this.connect(); + } + + handleGatewayUrlCancel() { + this.pendingGatewayUrl = null; + } + // Sidebar handlers for tool output viewing handleOpenSidebar(content: string) { if (this.sidebarCloseTimer != null) { diff --git a/ui/src/ui/views/gateway-url-confirmation.ts b/ui/src/ui/views/gateway-url-confirmation.ts new file mode 100644 index 000000000..7d48c4367 --- /dev/null +++ b/ui/src/ui/views/gateway-url-confirmation.ts @@ -0,0 +1,39 @@ +import { html, nothing } from "lit"; + +import type { AppViewState } from "../app-view-state"; + +export function renderGatewayUrlConfirmation(state: AppViewState) { + const { pendingGatewayUrl } = state; + if (!pendingGatewayUrl) return nothing; + + return html` + + `; +} From 67f1402703bb530246cf55e023d06c982ec8d991 Mon Sep 17 00:00:00 2001 From: Shakker Date: Wed, 28 Jan 2026 23:30:29 +0000 Subject: [PATCH 32/58] fix: tts base url runtime read (#3341) (thanks @hclsys) --- CHANGELOG.md | 1 + src/tts/tts.ts | 22 +++++++++++++++------- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5909c9899..37ae5fdf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,7 @@ Status: beta. - Agents: prevent retries on oversized image errors and surface size limits. (#2871) Thanks @Suksham-sharma. - Agents: inherit provider baseUrl/api for inline models. (#2740) Thanks @lploc94. - Memory Search: keep auto provider model defaults and only include remote when configured. (#2576) Thanks @papago2355. +- TTS: read OPENAI_TTS_BASE_URL at runtime instead of module load to honor config.env. (#3341) Thanks @hclsys. - macOS: auto-scroll to bottom when sending a new message while scrolled up. (#2471) Thanks @kennyklee. - Web UI: auto-expand the chat compose textarea while typing (with sensible max height). (#2950) Thanks @shivamraut101. - Gateway: prevent crashes on transient network errors (fetch failures, timeouts, DNS). Added fatal error detection to only exit on truly critical errors. Fixes #2895, #2879, #2873. (#2980) Thanks @elliotsecops. diff --git a/src/tts/tts.ts b/src/tts/tts.ts index af3d7fda5..faa83d3a6 100644 --- a/src/tts/tts.ts +++ b/src/tts/tts.ts @@ -757,11 +757,19 @@ export const OPENAI_TTS_MODELS = ["gpt-4o-mini-tts", "tts-1", "tts-1-hd"] as con * Custom OpenAI-compatible TTS endpoint. * When set, model/voice validation is relaxed to allow non-OpenAI models. * Example: OPENAI_TTS_BASE_URL=http://localhost:8880/v1 + * + * Note: Read at runtime (not module load) to support config.env loading. */ -const OPENAI_TTS_BASE_URL = ( - process.env.OPENAI_TTS_BASE_URL?.trim() || "https://api.openai.com/v1" -).replace(/\/+$/, ""); -const isCustomOpenAIEndpoint = OPENAI_TTS_BASE_URL !== "https://api.openai.com/v1"; +function getOpenAITtsBaseUrl(): string { + return (process.env.OPENAI_TTS_BASE_URL?.trim() || "https://api.openai.com/v1").replace( + /\/+$/, + "", + ); +} + +function isCustomOpenAIEndpoint(): boolean { + return getOpenAITtsBaseUrl() !== "https://api.openai.com/v1"; +} export const OPENAI_TTS_VOICES = [ "alloy", "ash", @@ -778,13 +786,13 @@ type OpenAiTtsVoice = (typeof OPENAI_TTS_VOICES)[number]; function isValidOpenAIModel(model: string): boolean { // Allow any model when using custom endpoint (e.g., Kokoro, LocalAI) - if (isCustomOpenAIEndpoint) return true; + if (isCustomOpenAIEndpoint()) return true; return OPENAI_TTS_MODELS.includes(model as (typeof OPENAI_TTS_MODELS)[number]); } function isValidOpenAIVoice(voice: string): voice is OpenAiTtsVoice { // Allow any voice when using custom endpoint (e.g., Kokoro Chinese voices) - if (isCustomOpenAIEndpoint) return true; + if (isCustomOpenAIEndpoint()) return true; return OPENAI_TTS_VOICES.includes(voice as OpenAiTtsVoice); } @@ -1011,7 +1019,7 @@ async function openaiTTS(params: { const timeout = setTimeout(() => controller.abort(), timeoutMs); try { - const response = await fetch(`${OPENAI_TTS_BASE_URL}/audio/speech`, { + const response = await fetch(`${getOpenAITtsBaseUrl()}/audio/speech`, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, From 1c98b9dec8da59cb44c4c1c28269a9d6ec92f4b3 Mon Sep 17 00:00:00 2001 From: Shakker Date: Wed, 28 Jan 2026 23:41:33 +0000 Subject: [PATCH 33/58] fix(ui): trim whitespace from config input fields on change --- ui/src/ui/views/config-form.node.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ui/src/ui/views/config-form.node.ts b/ui/src/ui/views/config-form.node.ts index 9d121d7f1..17a182281 100644 --- a/ui/src/ui/views/config-form.node.ts +++ b/ui/src/ui/views/config-form.node.ts @@ -260,6 +260,11 @@ function renderTextInput(params: { } onPatch(path, raw); }} + @change=${(e: Event) => { + if (inputType === "number") return; + const raw = (e.target as HTMLInputElement).value; + onPatch(path, raw.trim()); + }} /> ${schema.default !== undefined ? html` @@ -132,15 +138,47 @@ export function renderChatControls(state: AppViewState) { `; } -function resolveSessionOptions(sessionKey: string, sessions: SessionsListResult | null) { +type SessionDefaultsSnapshot = { + mainSessionKey?: string; + mainKey?: string; +}; + +function resolveMainSessionKey( + hello: AppViewState["hello"], + sessions: SessionsListResult | null, +): string | null { + const snapshot = hello?.snapshot as { sessionDefaults?: SessionDefaultsSnapshot } | undefined; + const mainSessionKey = snapshot?.sessionDefaults?.mainSessionKey?.trim(); + if (mainSessionKey) return mainSessionKey; + const mainKey = snapshot?.sessionDefaults?.mainKey?.trim(); + if (mainKey) return mainKey; + if (sessions?.sessions?.some((row) => row.key === "main")) return "main"; + return null; +} + +function resolveSessionOptions( + sessionKey: string, + sessions: SessionsListResult | null, + mainSessionKey?: string | null, +) { const seen = new Set(); const options: Array<{ key: string; displayName?: string }> = []; + const resolvedMain = + mainSessionKey && sessions?.sessions?.find((s) => s.key === mainSessionKey); const resolvedCurrent = sessions?.sessions?.find((s) => s.key === sessionKey); - // Add current session key first - seen.add(sessionKey); - options.push({ key: sessionKey, displayName: resolvedCurrent?.displayName }); + // Add main session key first + if (mainSessionKey) { + seen.add(mainSessionKey); + options.push({ key: mainSessionKey, displayName: resolvedMain?.displayName }); + } + + // Add current session key next + if (!seen.has(sessionKey)) { + seen.add(sessionKey); + options.push({ key: sessionKey, displayName: resolvedCurrent?.displayName }); + } // Add sessions from the result if (sessions?.sessions) { diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 26f4a5836..50ffcdf76 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -258,6 +258,7 @@ export class MoltbotApp extends LitElement { private logsScrollFrame: number | null = null; private toolStreamById = new Map(); private toolStreamOrder: string[] = []; + refreshSessionsAfterChat = false; basePath = ""; private popStateHandler = () => onPopStateInternal( diff --git a/ui/src/ui/controllers/sessions.ts b/ui/src/ui/controllers/sessions.ts index 5c5077037..7e87f1911 100644 --- a/ui/src/ui/controllers/sessions.ts +++ b/ui/src/ui/controllers/sessions.ts @@ -14,18 +14,29 @@ export type SessionsState = { sessionsIncludeUnknown: boolean; }; -export async function loadSessions(state: SessionsState) { +export async function loadSessions( + state: SessionsState, + overrides?: { + activeMinutes?: number; + limit?: number; + includeGlobal?: boolean; + includeUnknown?: boolean; + }, +) { if (!state.client || !state.connected) return; if (state.sessionsLoading) return; state.sessionsLoading = true; state.sessionsError = null; try { + const includeGlobal = overrides?.includeGlobal ?? state.sessionsIncludeGlobal; + const includeUnknown = overrides?.includeUnknown ?? state.sessionsIncludeUnknown; + const activeMinutes = + overrides?.activeMinutes ?? toNumber(state.sessionsFilterActive, 0); + const limit = overrides?.limit ?? toNumber(state.sessionsFilterLimit, 0); const params: Record = { - includeGlobal: state.sessionsIncludeGlobal, - includeUnknown: state.sessionsIncludeUnknown, + includeGlobal, + includeUnknown, }; - const activeMinutes = toNumber(state.sessionsFilterActive, 0); - const limit = toNumber(state.sessionsFilterLimit, 0); if (activeMinutes > 0) params.activeMinutes = activeMinutes; if (limit > 0) params.limit = limit; const res = (await state.client.request("sessions.list", params)) as From c41ea252b0451c9342638c746f4db3098cd5ef26 Mon Sep 17 00:00:00 2001 From: Josh Palmer Date: Thu, 29 Jan 2026 11:05:11 +0100 Subject: [PATCH 57/58] fix flaky web-fetch tests + lock cleanup What: - stub resolvePinnedHostname in web-fetch tests to avoid DNS flake - close lock file handles via FileHandle.close during cleanup to avoid EBADF Why: - make CI deterministic without network/DNS dependence - prevent double-close errors from GC Tests: - pnpm vitest run --config vitest.unit.config.ts src/agents/tools/web-tools.fetch.test.ts src/agents/session-write-lock.test.ts (failed: missing @aws-sdk/client-bedrock) --- src/agents/session-write-lock.ts | 4 ++-- src/agents/tools/web-tools.fetch.test.ts | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/agents/session-write-lock.ts b/src/agents/session-write-lock.ts index 832d368a6..82a2428da 100644 --- a/src/agents/session-write-lock.ts +++ b/src/agents/session-write-lock.ts @@ -35,8 +35,8 @@ function isAlive(pid: number): boolean { function releaseAllLocksSync(): void { for (const [sessionFile, held] of HELD_LOCKS) { try { - if (typeof held.handle.fd === "number") { - fsSync.closeSync(held.handle.fd); + if (typeof held.handle.close === "function") { + void held.handle.close().catch(() => {}); } } catch { // Ignore errors during cleanup - best effort diff --git a/src/agents/tools/web-tools.fetch.test.ts b/src/agents/tools/web-tools.fetch.test.ts index 04923b607..86bdeb7a2 100644 --- a/src/agents/tools/web-tools.fetch.test.ts +++ b/src/agents/tools/web-tools.fetch.test.ts @@ -1,5 +1,6 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as ssrf from "../../infra/net/ssrf.js"; import { createWebFetchTool } from "./web-tools.js"; type MockResponse = { @@ -73,6 +74,18 @@ function requestUrl(input: RequestInfo): string { describe("web_fetch extraction fallbacks", () => { const priorFetch = global.fetch; + beforeEach(() => { + vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation(async (hostname) => { + const normalized = hostname.trim().toLowerCase().replace(/\.$/, ""); + const addresses = ["93.184.216.34", "93.184.216.35"]; + return { + hostname: normalized, + addresses, + lookup: ssrf.createPinnedLookup({ hostname: normalized, addresses }), + }; + }); + }); + afterEach(() => { // @ts-expect-error restore global.fetch = priorFetch; From 5f4715acfc907420f0629545da9dbbcf695653a3 Mon Sep 17 00:00:00 2001 From: Josh Palmer Date: Thu, 29 Jan 2026 12:14:27 +0100 Subject: [PATCH 58/58] fix flaky gateway tests in CI What: - resolve shell from PATH in bash-tools tests (avoid /bin/bash dependency) - mock DNS for web-fetch SSRF tests (no real network) - stub a2ui bundle in canvas-host server test when missing Why: - keep gateway test suite deterministic on Nix/Garnix Linux Tests: - not run locally (known missing deps in unit test run) --- src/agents/bash-tools.test.ts | 23 +++++++++++++++++++++-- src/agents/tools/web-fetch.ssrf.test.ts | 15 ++++++++++----- src/canvas-host/server.test.ts | 13 +++++++++++++ 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/src/agents/bash-tools.test.ts b/src/agents/bash-tools.test.ts index 6990d3a76..6747aadc8 100644 --- a/src/agents/bash-tools.test.ts +++ b/src/agents/bash-tools.test.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -8,6 +9,24 @@ import { buildDockerExecArgs } from "./bash-tools.shared.js"; import { sanitizeBinaryOutput } from "./shell-utils.js"; const isWin = process.platform === "win32"; +const resolveShellFromPath = (name: string) => { + const envPath = process.env.PATH ?? ""; + if (!envPath) return undefined; + const entries = envPath.split(path.delimiter).filter(Boolean); + for (const entry of entries) { + const candidate = path.join(entry, name); + try { + fs.accessSync(candidate, fs.constants.X_OK); + return candidate; + } catch { + // ignore missing or non-executable entries + } + } + return undefined; +}; +const defaultShell = isWin + ? undefined + : process.env.CLAWDBOT_TEST_SHELL || resolveShellFromPath("bash") || process.env.SHELL || "sh"; // PowerShell: Start-Sleep for delays, ; for command separation, $null for null device const shortDelayCmd = isWin ? "Start-Sleep -Milliseconds 50" : "sleep 0.05"; const yieldDelayCmd = isWin ? "Start-Sleep -Milliseconds 200" : "sleep 0.2"; @@ -52,7 +71,7 @@ describe("exec tool backgrounding", () => { const originalShell = process.env.SHELL; beforeEach(() => { - if (!isWin) process.env.SHELL = "/bin/bash"; + if (!isWin && defaultShell) process.env.SHELL = defaultShell; }); afterEach(() => { @@ -282,7 +301,7 @@ describe("exec PATH handling", () => { const originalShell = process.env.SHELL; beforeEach(() => { - if (!isWin) process.env.SHELL = "/bin/bash"; + if (!isWin && defaultShell) process.env.SHELL = defaultShell; }); afterEach(() => { diff --git a/src/agents/tools/web-fetch.ssrf.test.ts b/src/agents/tools/web-fetch.ssrf.test.ts index 24e4dfe41..b5c1936b1 100644 --- a/src/agents/tools/web-fetch.ssrf.test.ts +++ b/src/agents/tools/web-fetch.ssrf.test.ts @@ -1,10 +1,9 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import * as ssrf from "../../infra/net/ssrf.js"; const lookupMock = vi.fn(); - -vi.mock("node:dns/promises", () => ({ - lookup: lookupMock, -})); +const resolvePinnedHostname = ssrf.resolvePinnedHostname; function makeHeaders(map: Record): { get: (key: string) => string | null } { return { @@ -33,6 +32,12 @@ function textResponse(body: string): Response { describe("web_fetch SSRF protection", () => { const priorFetch = global.fetch; + beforeEach(() => { + vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation((hostname) => + resolvePinnedHostname(hostname, lookupMock), + ); + }); + afterEach(() => { // @ts-expect-error restore global.fetch = priorFetch; diff --git a/src/canvas-host/server.test.ts b/src/canvas-host/server.test.ts index e460b2630..4577a16ea 100644 --- a/src/canvas-host/server.test.ts +++ b/src/canvas-host/server.test.ts @@ -202,6 +202,16 @@ describe("canvas host", () => { it("serves the gateway-hosted A2UI scaffold", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-canvas-")); + const a2uiRoot = path.resolve(process.cwd(), "src/canvas-host/a2ui"); + const bundlePath = path.join(a2uiRoot, "a2ui.bundle.js"); + let createdBundle = false; + + try { + await fs.stat(bundlePath); + } catch { + await fs.writeFile(bundlePath, "window.moltbotA2UI = {};", "utf8"); + createdBundle = true; + } const server = await startCanvasHost({ runtime: defaultRuntime, @@ -226,6 +236,9 @@ describe("canvas host", () => { expect(js).toContain("moltbotA2UI"); } finally { await server.close(); + if (createdBundle) { + await fs.rm(bundlePath, { force: true }); + } await fs.rm(dir, { recursive: true, force: true }); } });