From d75aa6cc437c32c98ec30c34798e17e5724593e9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 02:33:26 +0000 Subject: [PATCH] fix: harden lobster plugin tool (#1152) (thanks @vignesh07) --- CHANGELOG.md | 1 + docs/docs.json | 1 + docs/tools/lobster.md | 8 +- extensions/lobster/clawdbot.plugin.json | 8 ++ extensions/lobster/src/lobster-tool.test.ts | 89 ++++++++++++++++++- extensions/lobster/src/lobster-tool.ts | 42 +++++++-- src/cli/update-cli.test.ts | 25 +++++- ...event-handler.typing-read-receipts.test.ts | 2 +- 8 files changed, 159 insertions(+), 17 deletions(-) create mode 100644 extensions/lobster/clawdbot.plugin.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 9064f5dad..0bcd86b6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.clawd.bot ### Fixes - Doctor: warn when gateway.mode is unset with configure/config guidance. +- Lobster: fix plugin discovery and harden the tool runtime. (#1152) Thanks @vignesh07. ## 2026.1.21 diff --git a/docs/docs.json b/docs/docs.json index 72b5765a3..f8a316b14 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -977,6 +977,7 @@ "plugin", "plugins/voice-call", "plugins/zalouser", + "tools/lobster", "tools/exec", "tools/web", "tools/apply-patch", diff --git a/docs/tools/lobster.md b/docs/tools/lobster.md index bb3f4d130..277a9d8dc 100644 --- a/docs/tools/lobster.md +++ b/docs/tools/lobster.md @@ -20,18 +20,18 @@ Today, complex workflows require many back-and-forth tool calls. Each call costs Without Lobster: ``` User: "Check my email and draft replies" -→ clawd calls gmail.list +→ clawdbot calls gmail.list → LLM summarizes → User: "draft replies to #2 and #5" → LLM drafts → User: "send #2" -→ clawd calls gmail.send +→ clawdbot calls gmail.send (repeat daily, no memory of what was triaged) ``` With Lobster: ``` -clawd calls: lobster.run("email.triage --limit 20") +clawdbot calls: lobster.run("email.triage --limit 20") Returns: { @@ -46,7 +46,7 @@ Returns: } } -User approves → clawd calls: lobster.resume(token, approve: true) +User approves → clawdbot calls: lobster.resume(token, approve: true) → Emails sent ``` diff --git a/extensions/lobster/clawdbot.plugin.json b/extensions/lobster/clawdbot.plugin.json new file mode 100644 index 000000000..87c103c8b --- /dev/null +++ b/extensions/lobster/clawdbot.plugin.json @@ -0,0 +1,8 @@ +{ + "id": "lobster", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index 1c69b3280..fcfe0f939 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -8,13 +8,26 @@ import type { ClawdbotPluginApi, ClawdbotPluginToolContext } from "../../../src/ import { createLobsterTool } from "./lobster-tool.js"; async function writeFakeLobster(params: { - payload: unknown; + payload?: unknown; + stdout?: string; + stderr?: string; + exitCode?: number; + delayMs?: number; }) { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lobster-plugin-")); const binPath = path.join(dir, "lobster"); + const payload = params.stdout ?? JSON.stringify(params.payload ?? null); + const delay = Math.max(0, params.delayMs ?? 0); + const exitCode = Number.isFinite(params.exitCode) ? params.exitCode : 0; + const stderr = params.stderr ? String(params.stderr) : ""; + const file = `#!/usr/bin/env node\n` + - `process.stdout.write(JSON.stringify(${JSON.stringify(params.payload)}));\n`; + `setTimeout(() => {\n` + + ` if (${JSON.stringify(stderr)}.length) process.stderr.write(${JSON.stringify(stderr)});\n` + + ` process.stdout.write(${JSON.stringify(payload)});\n` + + ` process.exit(${exitCode});\n` + + `}, ${delay});\n`; await fs.writeFile(binPath, file, { encoding: "utf8", mode: 0o755 }); return { dir, binPath }; @@ -99,6 +112,78 @@ describe("lobster plugin tool", () => { ).rejects.toThrow(/invalid JSON/); }); + it("errors on timeout", async () => { + const fake = await writeFakeLobster({ + payload: { ok: true, status: "ok", output: [], requiresApproval: null }, + delayMs: 250, + }); + + const tool = createLobsterTool(fakeApi()); + await expect( + tool.execute("call4", { + action: "run", + pipeline: "noop", + lobsterPath: fake.binPath, + timeoutMs: 50, + }), + ).rejects.toThrow(/timed out/); + }); + + it("caps stdout", async () => { + const fake = await writeFakeLobster({ + stdout: "x".repeat(2000), + }); + + const tool = createLobsterTool(fakeApi()); + await expect( + tool.execute("call5", { + action: "run", + pipeline: "noop", + lobsterPath: fake.binPath, + maxStdoutBytes: 128, + }), + ).rejects.toThrow(/maxStdoutBytes/); + }); + + it("returns stderr in non-zero exit errors", async () => { + const fake = await writeFakeLobster({ + stdout: "", + stderr: "boom", + exitCode: 2, + }); + + const tool = createLobsterTool(fakeApi()); + await expect( + tool.execute("call6", { + action: "run", + pipeline: "noop", + lobsterPath: fake.binPath, + }), + ).rejects.toThrow(/boom/); + }); + + it("aborts via signal", async () => { + const fake = await writeFakeLobster({ + payload: { ok: true, status: "ok", output: [], requiresApproval: null }, + delayMs: 200, + }); + + const tool = createLobsterTool(fakeApi()); + const controller = new AbortController(); + const promise = tool.execute( + "call7", + { + action: "run", + pipeline: "noop", + lobsterPath: fake.binPath, + }, + controller.signal, + ); + + controller.abort(); + await expect(promise).rejects.toThrow(/aborted/); + }); + it("can be gated off in sandboxed contexts", async () => { const api = fakeApi(); const factoryTool = (ctx: ClawdbotPluginToolContext) => { diff --git a/extensions/lobster/src/lobster-tool.ts b/extensions/lobster/src/lobster-tool.ts index 2d76e0821..4b89367d2 100644 --- a/extensions/lobster/src/lobster-tool.ts +++ b/extensions/lobster/src/lobster-tool.ts @@ -35,12 +35,18 @@ async function runLobsterSubprocess(params: { cwd: string; timeoutMs: number; maxStdoutBytes: number; + signal?: AbortSignal; }) { const { execPath, argv, cwd } = params; const timeoutMs = Math.max(200, params.timeoutMs); const maxStdoutBytes = Math.max(1024, params.maxStdoutBytes); return await new Promise<{ stdout: string }>((resolve, reject) => { + if (params.signal?.aborted) { + reject(new Error("lobster subprocess aborted")); + return; + } + const child = spawn(execPath, argv, { cwd, stdio: ["ignore", "pipe", "pipe"], @@ -53,6 +59,25 @@ async function runLobsterSubprocess(params: { let stdout = ""; let stdoutBytes = 0; let stderr = ""; + let settled = false; + + const finish = (fn: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (params.signal) params.signal.removeEventListener("abort", onAbort); + fn(); + }; + + const onAbort = () => { + try { + child.kill("SIGKILL"); + } finally { + finish(() => reject(new Error("lobster subprocess aborted"))); + } + }; + + params.signal?.addEventListener("abort", onAbort); child.stdout?.setEncoding("utf8"); child.stderr?.setEncoding("utf8"); @@ -64,7 +89,7 @@ async function runLobsterSubprocess(params: { try { child.kill("SIGKILL"); } finally { - reject(new Error("lobster output exceeded maxStdoutBytes")); + finish(() => reject(new Error("lobster output exceeded maxStdoutBytes"))); } return; } @@ -79,22 +104,22 @@ async function runLobsterSubprocess(params: { try { child.kill("SIGKILL"); } finally { - reject(new Error("lobster subprocess timed out")); + finish(() => reject(new Error("lobster subprocess timed out"))); } }, timeoutMs); child.once("error", (err) => { - clearTimeout(timer); - reject(err); + finish(() => reject(err)); }); child.once("exit", (code) => { - clearTimeout(timer); if (code !== 0) { - reject(new Error(`lobster failed (${code ?? "?"}): ${stderr.trim() || stdout.trim()}`)); + finish(() => + reject(new Error(`lobster failed (${code ?? "?"}): ${stderr.trim() || stdout.trim()}`)), + ); return; } - resolve({ stdout }); + finish(() => resolve({ stdout })); }); }); } @@ -135,7 +160,7 @@ export function createLobsterTool(api: ClawdbotPluginApi) { timeoutMs: Type.Optional(Type.Number()), maxStdoutBytes: Type.Optional(Type.Number()), }), - async execute(_id: string, params: Record) { + async execute(_id: string, params: Record, signal?: AbortSignal) { const action = String(params.action || "").trim(); if (!action) throw new Error("action required"); @@ -172,6 +197,7 @@ export function createLobsterTool(api: ClawdbotPluginApi) { cwd, timeoutMs, maxStdoutBytes, + signal, }); const envelope = parseEnvelope(stdout); diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 395789bdd..8b07a80f1 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { UpdateRunResult } from "../infra/update-runner.js"; +import { parseSemver } from "../infra/runtime-guard.js"; // Mock the update-runner module vi.mock("../infra/update-runner.js", () => ({ @@ -74,6 +75,23 @@ describe("update-cli", () => { }); }; + const readRepoVersion = async () => { + const raw = await fs.readFile(new URL("../../package.json", import.meta.url), "utf-8"); + const parsed = JSON.parse(raw) as { version?: string }; + if (!parsed.version) { + throw new Error("package.json version missing"); + } + return parsed.version; + }; + + const bumpPatch = (version: string, delta: number) => { + const parsed = parseSemver(version); + if (!parsed) { + throw new Error(`invalid version: ${version}`); + } + return `${parsed.major}.${parsed.minor}.${Math.max(0, parsed.patch + delta)}`; + }; + beforeEach(async () => { vi.clearAllMocks(); const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js"); @@ -271,9 +289,12 @@ describe("update-cli", () => { it("falls back to latest when beta tag is older than release", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-update-")); try { + const repoVersion = await readRepoVersion(); + const currentVersion = bumpPatch(repoVersion, 0); + const latestVersion = bumpPatch(repoVersion, 1); await fs.writeFile( path.join(tempDir, "package.json"), - JSON.stringify({ name: "clawdbot", version: "2026.1.18-1" }), + JSON.stringify({ name: "clawdbot", version: currentVersion }), "utf-8", ); @@ -302,7 +323,7 @@ describe("update-cli", () => { }); vi.mocked(resolveNpmChannelTag).mockResolvedValue({ tag: "latest", - version: "1.2.3-1", + version: latestVersion, }); vi.mocked(runGatewayUpdate).mockResolvedValue({ status: "ok", diff --git a/src/signal/monitor.event-handler.typing-read-receipts.test.ts b/src/signal/monitor.event-handler.typing-read-receipts.test.ts index 2985f4d95..c4ba6f9ce 100644 --- a/src/signal/monitor.event-handler.typing-read-receipts.test.ts +++ b/src/signal/monitor.event-handler.typing-read-receipts.test.ts @@ -12,7 +12,7 @@ vi.mock("./send.js", () => ({ vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({ dispatchReplyFromConfig: vi.fn( async (params: { replyOptions?: { onReplyStart?: () => void } }) => { - await params.replyOptions?.onReplyStart?.(); + await Promise.resolve(params.replyOptions?.onReplyStart?.()); return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } }; }, ),