From 9688454a30e618e878ca795fbe46da58b2e2e9d3 Mon Sep 17 00:00:00 2001 From: Shadow Date: Wed, 28 Jan 2026 01:12:04 -0600 Subject: [PATCH 01/35] 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 02/35] 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 03/35] 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 04/35] 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 05/35] 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 06/35] 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 07/35] 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 08/35] 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 09/35] 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 10/35] 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 34/35] 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 35/35] 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 }); } });