fix: harden lobster plugin tool (#1152) (thanks @vignesh07)
This commit is contained in:
parent
f582fd6b59
commit
d75aa6cc43
@ -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
|
||||
|
||||
|
||||
@ -977,6 +977,7 @@
|
||||
"plugin",
|
||||
"plugins/voice-call",
|
||||
"plugins/zalouser",
|
||||
"tools/lobster",
|
||||
"tools/exec",
|
||||
"tools/web",
|
||||
"tools/apply-patch",
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
|
||||
8
extensions/lobster/clawdbot.plugin.json
Normal file
8
extensions/lobster/clawdbot.plugin.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "lobster",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
@ -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) => {
|
||||
|
||||
@ -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<string, unknown>) {
|
||||
async execute(_id: string, params: Record<string, unknown>, 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);
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 } };
|
||||
},
|
||||
),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user