@openclaw/lobster 2026.2.13 → 2026.2.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/lobster-tool.test.ts +132 -138
- package/src/lobster-tool.ts +1 -1
package/package.json
CHANGED
package/src/lobster-tool.test.ts
CHANGED
|
@@ -1,35 +1,21 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
1
2
|
import fs from "node:fs/promises";
|
|
2
3
|
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
4
|
-
import {
|
|
5
|
+
import { PassThrough } from "node:stream";
|
|
6
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
7
|
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../../../src/plugins/types.js";
|
|
6
|
-
import { createLobsterTool } from "./lobster-tool.js";
|
|
7
|
-
|
|
8
|
-
async function writeFakeLobsterScript(scriptBody: string, prefix = "openclaw-lobster-plugin-") {
|
|
9
|
-
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
10
|
-
const isWindows = process.platform === "win32";
|
|
11
|
-
|
|
12
|
-
if (isWindows) {
|
|
13
|
-
const scriptPath = path.join(dir, "lobster.js");
|
|
14
|
-
const cmdPath = path.join(dir, "lobster.cmd");
|
|
15
|
-
await fs.writeFile(scriptPath, scriptBody, { encoding: "utf8" });
|
|
16
|
-
const cmd = `@echo off\r\n"${process.execPath}" "${scriptPath}" %*\r\n`;
|
|
17
|
-
await fs.writeFile(cmdPath, cmd, { encoding: "utf8" });
|
|
18
|
-
return { dir, binPath: cmdPath };
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const binPath = path.join(dir, "lobster");
|
|
22
|
-
const file = `#!/usr/bin/env node\n${scriptBody}\n`;
|
|
23
|
-
await fs.writeFile(binPath, file, { encoding: "utf8", mode: 0o755 });
|
|
24
|
-
return { dir, binPath };
|
|
25
|
-
}
|
|
26
8
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
9
|
+
const spawnState = vi.hoisted(() => ({
|
|
10
|
+
queue: [] as Array<{ stdout: string; stderr?: string; exitCode?: number }>,
|
|
11
|
+
spawn: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("node:child_process", () => ({
|
|
15
|
+
spawn: (...args: unknown[]) => spawnState.spawn(...args),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
let createLobsterTool: typeof import("./lobster-tool.js").createLobsterTool;
|
|
33
19
|
|
|
34
20
|
function fakeApi(overrides: Partial<OpenClawPluginApi> = {}): OpenClawPluginApi {
|
|
35
21
|
return {
|
|
@@ -72,96 +58,115 @@ function fakeCtx(overrides: Partial<OpenClawPluginToolContext> = {}): OpenClawPl
|
|
|
72
58
|
}
|
|
73
59
|
|
|
74
60
|
describe("lobster plugin tool", () => {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
payload: { ok: true, status: "ok", output: [{ hello: "world" }], requiresApproval: null },
|
|
78
|
-
});
|
|
61
|
+
let tempDir = "";
|
|
62
|
+
let lobsterBinPath = "";
|
|
79
63
|
|
|
80
|
-
|
|
81
|
-
|
|
64
|
+
beforeAll(async () => {
|
|
65
|
+
({ createLobsterTool } = await import("./lobster-tool.js"));
|
|
82
66
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
pipeline: "noop",
|
|
88
|
-
timeoutMs: 1000,
|
|
89
|
-
});
|
|
67
|
+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-plugin-"));
|
|
68
|
+
lobsterBinPath = path.join(tempDir, process.platform === "win32" ? "lobster.cmd" : "lobster");
|
|
69
|
+
await fs.writeFile(lobsterBinPath, "", { encoding: "utf8", mode: 0o755 });
|
|
70
|
+
});
|
|
90
71
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
72
|
+
afterAll(async () => {
|
|
73
|
+
if (!tempDir) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (process.platform === "win32") {
|
|
77
|
+
await fs.rm(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 });
|
|
78
|
+
} else {
|
|
79
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
94
80
|
}
|
|
95
81
|
});
|
|
96
82
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
spawnState.queue.length = 0;
|
|
85
|
+
spawnState.spawn.mockReset();
|
|
86
|
+
spawnState.spawn.mockImplementation(() => {
|
|
87
|
+
const next = spawnState.queue.shift() ?? { stdout: "" };
|
|
88
|
+
const stdout = new PassThrough();
|
|
89
|
+
const stderr = new PassThrough();
|
|
90
|
+
const child = new EventEmitter() as EventEmitter & {
|
|
91
|
+
stdout: PassThrough;
|
|
92
|
+
stderr: PassThrough;
|
|
93
|
+
kill: (signal?: string) => boolean;
|
|
94
|
+
};
|
|
95
|
+
child.stdout = stdout;
|
|
96
|
+
child.stderr = stderr;
|
|
97
|
+
child.kill = () => true;
|
|
98
|
+
|
|
99
|
+
setImmediate(() => {
|
|
100
|
+
if (next.stderr) {
|
|
101
|
+
stderr.end(next.stderr);
|
|
102
|
+
} else {
|
|
103
|
+
stderr.end();
|
|
104
|
+
}
|
|
105
|
+
stdout.end(next.stdout);
|
|
106
|
+
child.emit("exit", next.exitCode ?? 0);
|
|
115
107
|
});
|
|
116
108
|
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
process.env.PATH = originalPath;
|
|
120
|
-
}
|
|
109
|
+
return child;
|
|
110
|
+
});
|
|
121
111
|
});
|
|
122
112
|
|
|
123
|
-
it("
|
|
124
|
-
|
|
125
|
-
|
|
113
|
+
it("runs lobster and returns parsed envelope in details", async () => {
|
|
114
|
+
spawnState.queue.push({
|
|
115
|
+
stdout: JSON.stringify({
|
|
116
|
+
ok: true,
|
|
117
|
+
status: "ok",
|
|
118
|
+
output: [{ hello: "world" }],
|
|
119
|
+
requiresApproval: null,
|
|
120
|
+
}),
|
|
126
121
|
});
|
|
127
122
|
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
lobsterPath: "./lobster",
|
|
138
|
-
}),
|
|
139
|
-
).rejects.toThrow(/absolute path/);
|
|
140
|
-
} finally {
|
|
141
|
-
process.env.PATH = originalPath;
|
|
142
|
-
}
|
|
123
|
+
const tool = createLobsterTool(fakeApi());
|
|
124
|
+
const res = await tool.execute("call1", {
|
|
125
|
+
action: "run",
|
|
126
|
+
pipeline: "noop",
|
|
127
|
+
timeoutMs: 1000,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(spawnState.spawn).toHaveBeenCalled();
|
|
131
|
+
expect(res.details).toMatchObject({ ok: true, status: "ok" });
|
|
143
132
|
});
|
|
144
133
|
|
|
145
|
-
it("
|
|
146
|
-
const
|
|
147
|
-
|
|
134
|
+
it("tolerates noisy stdout before the JSON envelope", async () => {
|
|
135
|
+
const payload = { ok: true, status: "ok", output: [], requiresApproval: null };
|
|
136
|
+
spawnState.queue.push({
|
|
137
|
+
stdout: `noise before json\n${JSON.stringify(payload)}`,
|
|
148
138
|
});
|
|
149
139
|
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
140
|
+
const tool = createLobsterTool(fakeApi());
|
|
141
|
+
const res = await tool.execute("call-noisy", {
|
|
142
|
+
action: "run",
|
|
143
|
+
pipeline: "noop",
|
|
144
|
+
timeoutMs: 1000,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(res.details).toMatchObject({ ok: true, status: "ok" });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("requires absolute lobsterPath when provided (even though it is ignored)", async () => {
|
|
151
|
+
const tool = createLobsterTool(fakeApi());
|
|
152
|
+
await expect(
|
|
153
|
+
tool.execute("call2", {
|
|
154
|
+
action: "run",
|
|
155
|
+
pipeline: "noop",
|
|
156
|
+
lobsterPath: "./lobster",
|
|
157
|
+
}),
|
|
158
|
+
).rejects.toThrow(/absolute path/);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("rejects lobsterPath (deprecated) when invalid", async () => {
|
|
162
|
+
const tool = createLobsterTool(fakeApi());
|
|
163
|
+
await expect(
|
|
164
|
+
tool.execute("call2b", {
|
|
165
|
+
action: "run",
|
|
166
|
+
pipeline: "noop",
|
|
167
|
+
lobsterPath: "/bin/bash",
|
|
168
|
+
}),
|
|
169
|
+
).rejects.toThrow(/lobster executable/);
|
|
165
170
|
});
|
|
166
171
|
|
|
167
172
|
it("rejects absolute cwd", async () => {
|
|
@@ -187,49 +192,38 @@ describe("lobster plugin tool", () => {
|
|
|
187
192
|
});
|
|
188
193
|
|
|
189
194
|
it("uses pluginConfig.lobsterPath when provided", async () => {
|
|
190
|
-
|
|
191
|
-
|
|
195
|
+
spawnState.queue.push({
|
|
196
|
+
stdout: JSON.stringify({
|
|
197
|
+
ok: true,
|
|
198
|
+
status: "ok",
|
|
199
|
+
output: [{ hello: "world" }],
|
|
200
|
+
requiresApproval: null,
|
|
201
|
+
}),
|
|
192
202
|
});
|
|
193
203
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: fake.binPath } }));
|
|
201
|
-
const res = await tool.execute("call-plugin-config", {
|
|
202
|
-
action: "run",
|
|
203
|
-
pipeline: "noop",
|
|
204
|
-
timeoutMs: 1000,
|
|
205
|
-
});
|
|
204
|
+
const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: lobsterBinPath } }));
|
|
205
|
+
const res = await tool.execute("call-plugin-config", {
|
|
206
|
+
action: "run",
|
|
207
|
+
pipeline: "noop",
|
|
208
|
+
timeoutMs: 1000,
|
|
209
|
+
});
|
|
206
210
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
211
|
+
expect(spawnState.spawn).toHaveBeenCalled();
|
|
212
|
+
const [execPath] = spawnState.spawn.mock.calls[0] ?? [];
|
|
213
|
+
expect(execPath).toBe(lobsterBinPath);
|
|
214
|
+
expect(res.details).toMatchObject({ ok: true, status: "ok" });
|
|
211
215
|
});
|
|
212
216
|
|
|
213
217
|
it("rejects invalid JSON from lobster", async () => {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const tool = createLobsterTool(fakeApi());
|
|
224
|
-
await expect(
|
|
225
|
-
tool.execute("call3", {
|
|
226
|
-
action: "run",
|
|
227
|
-
pipeline: "noop",
|
|
228
|
-
}),
|
|
229
|
-
).rejects.toThrow(/invalid JSON/);
|
|
230
|
-
} finally {
|
|
231
|
-
process.env.PATH = originalPath;
|
|
232
|
-
}
|
|
218
|
+
spawnState.queue.push({ stdout: "nope" });
|
|
219
|
+
|
|
220
|
+
const tool = createLobsterTool(fakeApi());
|
|
221
|
+
await expect(
|
|
222
|
+
tool.execute("call3", {
|
|
223
|
+
action: "run",
|
|
224
|
+
pipeline: "noop",
|
|
225
|
+
}),
|
|
226
|
+
).rejects.toThrow(/invalid JSON/);
|
|
233
227
|
});
|
|
234
228
|
|
|
235
229
|
it("can be gated off in sandboxed contexts", async () => {
|
package/src/lobster-tool.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { Type } from "@sinclair/typebox";
|
|
2
1
|
import { spawn } from "node:child_process";
|
|
3
2
|
import fs from "node:fs";
|
|
4
3
|
import path from "node:path";
|
|
4
|
+
import { Type } from "@sinclair/typebox";
|
|
5
5
|
import type { OpenClawPluginApi } from "../../../src/plugins/types.js";
|
|
6
6
|
|
|
7
7
|
type LobsterEnvelope =
|