From 67f1402703bb530246cf55e023d06c982ec8d991 Mon Sep 17 00:00:00 2001 From: Shakker Date: Wed, 28 Jan 2026 23:30:29 +0000 Subject: [PATCH 01/27] 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 02/27] 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 26/27] 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 27/27] 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 }); } });