@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/lobster",
3
- "version": "2026.2.13",
3
+ "version": "2026.2.17",
4
4
  "description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
5
5
  "type": "module",
6
6
  "devDependencies": {
@@ -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 { describe, expect, it } from "vitest";
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
- async function writeFakeLobster(params: { payload: unknown }) {
28
- const scriptBody =
29
- `const payload = ${JSON.stringify(params.payload)};\n` +
30
- `process.stdout.write(JSON.stringify(payload));\n`;
31
- return await writeFakeLobsterScript(scriptBody);
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
- it("runs lobster and returns parsed envelope in details", async () => {
76
- const fake = await writeFakeLobster({
77
- payload: { ok: true, status: "ok", output: [{ hello: "world" }], requiresApproval: null },
78
- });
61
+ let tempDir = "";
62
+ let lobsterBinPath = "";
79
63
 
80
- const originalPath = process.env.PATH;
81
- process.env.PATH = `${fake.dir}${path.delimiter}${originalPath ?? ""}`;
64
+ beforeAll(async () => {
65
+ ({ createLobsterTool } = await import("./lobster-tool.js"));
82
66
 
83
- try {
84
- const tool = createLobsterTool(fakeApi());
85
- const res = await tool.execute("call1", {
86
- action: "run",
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
- expect(res.details).toMatchObject({ ok: true, status: "ok" });
92
- } finally {
93
- process.env.PATH = originalPath;
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
- it("tolerates noisy stdout before the JSON envelope", async () => {
98
- const payload = { ok: true, status: "ok", output: [], requiresApproval: null };
99
- const { dir } = await writeFakeLobsterScript(
100
- `const payload = ${JSON.stringify(payload)};\n` +
101
- `console.log("noise before json");\n` +
102
- `process.stdout.write(JSON.stringify(payload));\n`,
103
- "openclaw-lobster-plugin-noisy-",
104
- );
105
-
106
- const originalPath = process.env.PATH;
107
- process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ""}`;
108
-
109
- try {
110
- const tool = createLobsterTool(fakeApi());
111
- const res = await tool.execute("call-noisy", {
112
- action: "run",
113
- pipeline: "noop",
114
- timeoutMs: 1000,
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
- expect(res.details).toMatchObject({ ok: true, status: "ok" });
118
- } finally {
119
- process.env.PATH = originalPath;
120
- }
109
+ return child;
110
+ });
121
111
  });
122
112
 
123
- it("requires absolute lobsterPath when provided (even though it is ignored)", async () => {
124
- const fake = await writeFakeLobster({
125
- payload: { ok: true, status: "ok", output: [{ hello: "world" }], requiresApproval: null },
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 originalPath = process.env.PATH;
129
- process.env.PATH = `${fake.dir}${path.delimiter}${originalPath ?? ""}`;
130
-
131
- try {
132
- const tool = createLobsterTool(fakeApi());
133
- await expect(
134
- tool.execute("call2", {
135
- action: "run",
136
- pipeline: "noop",
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("rejects lobsterPath (deprecated) when invalid", async () => {
146
- const fake = await writeFakeLobster({
147
- payload: { ok: true, status: "ok", output: [{ hello: "world" }], requiresApproval: null },
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 originalPath = process.env.PATH;
151
- process.env.PATH = `${fake.dir}${path.delimiter}${originalPath ?? ""}`;
152
-
153
- try {
154
- const tool = createLobsterTool(fakeApi());
155
- await expect(
156
- tool.execute("call2b", {
157
- action: "run",
158
- pipeline: "noop",
159
- lobsterPath: "/bin/bash",
160
- }),
161
- ).rejects.toThrow(/lobster executable/);
162
- } finally {
163
- process.env.PATH = originalPath;
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
- const fake = await writeFakeLobster({
191
- payload: { ok: true, status: "ok", output: [{ hello: "world" }], requiresApproval: null },
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
- // Ensure `lobster` is NOT discoverable via PATH, while still allowing our
195
- // fake lobster (a Node script with `#!/usr/bin/env node`) to run.
196
- const originalPath = process.env.PATH;
197
- process.env.PATH = path.dirname(process.execPath);
198
-
199
- try {
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
- expect(res.details).toMatchObject({ ok: true, status: "ok" });
208
- } finally {
209
- process.env.PATH = originalPath;
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
- const { dir } = await writeFakeLobsterScript(
215
- `process.stdout.write("nope");\n`,
216
- "openclaw-lobster-plugin-bad-",
217
- );
218
-
219
- const originalPath = process.env.PATH;
220
- process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ""}`;
221
-
222
- try {
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 () => {
@@ -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 =