242 lines
6.8 KiB
TypeScript
242 lines
6.8 KiB
TypeScript
import { EventEmitter } from "node:events";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { PassThrough } from "node:stream";
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../../../src/plugins/types.js";
|
|
|
|
const spawnState = vi.hoisted(() => ({
|
|
queue: [] as Array<{ stdout: string; stderr?: string; exitCode?: number }>,
|
|
spawn: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("node:child_process", () => ({
|
|
spawn: (...args: unknown[]) => spawnState.spawn(...args),
|
|
}));
|
|
|
|
let createLobsterTool: typeof import("./lobster-tool.js").createLobsterTool;
|
|
|
|
function fakeApi(overrides: Partial<OpenClawPluginApi> = {}): OpenClawPluginApi {
|
|
return {
|
|
id: "lobster",
|
|
name: "lobster",
|
|
source: "test",
|
|
config: {},
|
|
pluginConfig: {},
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
runtime: { version: "test" } as any,
|
|
logger: { info() {}, warn() {}, error() {}, debug() {} },
|
|
registerTool() {},
|
|
registerHttpHandler() {},
|
|
registerChannel() {},
|
|
registerGatewayMethod() {},
|
|
registerCli() {},
|
|
registerService() {},
|
|
registerProvider() {},
|
|
registerHook() {},
|
|
registerHttpRoute() {},
|
|
registerCommand() {},
|
|
on() {},
|
|
resolvePath: (p) => p,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function fakeCtx(overrides: Partial<OpenClawPluginToolContext> = {}): OpenClawPluginToolContext {
|
|
return {
|
|
config: {},
|
|
workspaceDir: "/tmp",
|
|
agentDir: "/tmp",
|
|
agentId: "main",
|
|
sessionKey: "main",
|
|
messageChannel: undefined,
|
|
agentAccountId: undefined,
|
|
sandboxed: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("lobster plugin tool", () => {
|
|
let tempDir = "";
|
|
let lobsterBinPath = "";
|
|
|
|
beforeAll(async () => {
|
|
({ createLobsterTool } = await import("./lobster-tool.js"));
|
|
|
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-plugin-"));
|
|
lobsterBinPath = path.join(tempDir, process.platform === "win32" ? "lobster.cmd" : "lobster");
|
|
await fs.writeFile(lobsterBinPath, "", { encoding: "utf8", mode: 0o755 });
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (!tempDir) {
|
|
return;
|
|
}
|
|
if (process.platform === "win32") {
|
|
await fs.rm(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 });
|
|
} else {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
beforeEach(() => {
|
|
spawnState.queue.length = 0;
|
|
spawnState.spawn.mockReset();
|
|
spawnState.spawn.mockImplementation(() => {
|
|
const next = spawnState.queue.shift() ?? { stdout: "" };
|
|
const stdout = new PassThrough();
|
|
const stderr = new PassThrough();
|
|
const child = new EventEmitter() as EventEmitter & {
|
|
stdout: PassThrough;
|
|
stderr: PassThrough;
|
|
kill: (signal?: string) => boolean;
|
|
};
|
|
child.stdout = stdout;
|
|
child.stderr = stderr;
|
|
child.kill = () => true;
|
|
|
|
setImmediate(() => {
|
|
if (next.stderr) {
|
|
stderr.end(next.stderr);
|
|
} else {
|
|
stderr.end();
|
|
}
|
|
stdout.end(next.stdout);
|
|
child.emit("exit", next.exitCode ?? 0);
|
|
});
|
|
|
|
return child;
|
|
});
|
|
});
|
|
|
|
it("runs lobster and returns parsed envelope in details", async () => {
|
|
spawnState.queue.push({
|
|
stdout: JSON.stringify({
|
|
ok: true,
|
|
status: "ok",
|
|
output: [{ hello: "world" }],
|
|
requiresApproval: null,
|
|
}),
|
|
});
|
|
|
|
const tool = createLobsterTool(fakeApi());
|
|
const res = await tool.execute("call1", {
|
|
action: "run",
|
|
pipeline: "noop",
|
|
timeoutMs: 1000,
|
|
});
|
|
|
|
expect(spawnState.spawn).toHaveBeenCalled();
|
|
expect(res.details).toMatchObject({ ok: true, status: "ok" });
|
|
});
|
|
|
|
it("tolerates noisy stdout before the JSON envelope", async () => {
|
|
const payload = { ok: true, status: "ok", output: [], requiresApproval: null };
|
|
spawnState.queue.push({
|
|
stdout: `noise before json\n${JSON.stringify(payload)}`,
|
|
});
|
|
|
|
const tool = createLobsterTool(fakeApi());
|
|
const res = await tool.execute("call-noisy", {
|
|
action: "run",
|
|
pipeline: "noop",
|
|
timeoutMs: 1000,
|
|
});
|
|
|
|
expect(res.details).toMatchObject({ ok: true, status: "ok" });
|
|
});
|
|
|
|
it("requires absolute lobsterPath when provided (even though it is ignored)", async () => {
|
|
const tool = createLobsterTool(fakeApi());
|
|
await expect(
|
|
tool.execute("call2", {
|
|
action: "run",
|
|
pipeline: "noop",
|
|
lobsterPath: "./lobster",
|
|
}),
|
|
).rejects.toThrow(/absolute path/);
|
|
});
|
|
|
|
it("rejects lobsterPath (deprecated) when invalid", async () => {
|
|
const tool = createLobsterTool(fakeApi());
|
|
await expect(
|
|
tool.execute("call2b", {
|
|
action: "run",
|
|
pipeline: "noop",
|
|
lobsterPath: "/bin/bash",
|
|
}),
|
|
).rejects.toThrow(/lobster executable/);
|
|
});
|
|
|
|
it("rejects absolute cwd", async () => {
|
|
const tool = createLobsterTool(fakeApi());
|
|
await expect(
|
|
tool.execute("call2c", {
|
|
action: "run",
|
|
pipeline: "noop",
|
|
cwd: "/tmp",
|
|
}),
|
|
).rejects.toThrow(/cwd must be a relative path/);
|
|
});
|
|
|
|
it("rejects cwd that escapes the gateway working directory", async () => {
|
|
const tool = createLobsterTool(fakeApi());
|
|
await expect(
|
|
tool.execute("call2d", {
|
|
action: "run",
|
|
pipeline: "noop",
|
|
cwd: "../../etc",
|
|
}),
|
|
).rejects.toThrow(/must stay within/);
|
|
});
|
|
|
|
it("uses pluginConfig.lobsterPath when provided", async () => {
|
|
spawnState.queue.push({
|
|
stdout: JSON.stringify({
|
|
ok: true,
|
|
status: "ok",
|
|
output: [{ hello: "world" }],
|
|
requiresApproval: null,
|
|
}),
|
|
});
|
|
|
|
const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: lobsterBinPath } }));
|
|
const res = await tool.execute("call-plugin-config", {
|
|
action: "run",
|
|
pipeline: "noop",
|
|
timeoutMs: 1000,
|
|
});
|
|
|
|
expect(spawnState.spawn).toHaveBeenCalled();
|
|
const [execPath] = spawnState.spawn.mock.calls[0] ?? [];
|
|
expect(execPath).toBe(lobsterBinPath);
|
|
expect(res.details).toMatchObject({ ok: true, status: "ok" });
|
|
});
|
|
|
|
it("rejects invalid JSON from lobster", async () => {
|
|
spawnState.queue.push({ stdout: "nope" });
|
|
|
|
const tool = createLobsterTool(fakeApi());
|
|
await expect(
|
|
tool.execute("call3", {
|
|
action: "run",
|
|
pipeline: "noop",
|
|
}),
|
|
).rejects.toThrow(/invalid JSON/);
|
|
});
|
|
|
|
it("can be gated off in sandboxed contexts", async () => {
|
|
const api = fakeApi();
|
|
const factoryTool = (ctx: OpenClawPluginToolContext) => {
|
|
if (ctx.sandboxed) {
|
|
return null;
|
|
}
|
|
return createLobsterTool(api);
|
|
};
|
|
|
|
expect(factoryTool(fakeCtx({ sandboxed: true }))).toBeNull();
|
|
expect(factoryTool(fakeCtx({ sandboxed: false }))?.name).toBe("lobster");
|
|
});
|
|
});
|