@openclaw/lobster 2026.3.13 → 2026.5.1-beta.1

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.
@@ -1,56 +1,18 @@
1
- import { EventEmitter } from "node:events";
2
- import fs from "node:fs/promises";
3
- import os from "node:os";
4
- import path from "node:path";
5
- import { PassThrough } from "node:stream";
6
- import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk/lobster";
7
- import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
8
- import {
9
- createWindowsCmdShimFixture,
10
- restorePlatformPathEnv,
11
- setProcessPlatform,
12
- snapshotPlatformPathEnv,
13
- } from "./test-helpers.js";
14
-
15
- const spawnState = vi.hoisted(() => ({
16
- queue: [] as Array<{ stdout: string; stderr?: string; exitCode?: number }>,
17
- spawn: vi.fn(),
18
- }));
19
-
20
- vi.mock("node:child_process", async (importOriginal) => {
21
- const actual = await importOriginal<typeof import("node:child_process")>();
22
- return {
23
- ...actual,
24
- spawn: (...args: unknown[]) => spawnState.spawn(...args),
25
- };
26
- });
27
-
28
- let createLobsterTool: typeof import("./lobster-tool.js").createLobsterTool;
1
+ import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../runtime-api.js";
4
+ import { createLobsterTool } from "./lobster-tool.js";
5
+ import { createFakeTaskFlow } from "./taskflow-test-helpers.js";
29
6
 
30
7
  function fakeApi(overrides: Partial<OpenClawPluginApi> = {}): OpenClawPluginApi {
31
- return {
8
+ return createTestPluginApi({
32
9
  id: "lobster",
33
10
  name: "lobster",
34
11
  source: "test",
35
- config: {},
36
- pluginConfig: {},
37
- // oxlint-disable-next-line typescript/no-explicit-any
38
12
  runtime: { version: "test" } as any,
39
- logger: { info() {}, warn() {}, error() {}, debug() {} },
40
- registerTool() {},
41
- registerChannel() {},
42
- registerGatewayMethod() {},
43
- registerCli() {},
44
- registerService() {},
45
- registerProvider() {},
46
- registerHook() {},
47
- registerHttpRoute() {},
48
- registerCommand() {},
49
- registerContextEngine() {},
50
- on() {},
51
13
  resolvePath: (p) => p,
52
14
  ...overrides,
53
- };
15
+ });
54
16
  }
55
17
 
56
18
  function fakeCtx(overrides: Partial<OpenClawPluginToolContext> = {}): OpenClawPluginToolContext {
@@ -68,140 +30,279 @@ function fakeCtx(overrides: Partial<OpenClawPluginToolContext> = {}): OpenClawPl
68
30
  }
69
31
 
70
32
  describe("lobster plugin tool", () => {
71
- let tempDir = "";
72
- const originalProcessState = snapshotPlatformPathEnv();
73
-
74
- beforeAll(async () => {
75
- ({ createLobsterTool } = await import("./lobster-tool.js"));
76
-
77
- tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-plugin-"));
78
- });
79
-
80
- afterEach(() => {
81
- restorePlatformPathEnv(originalProcessState);
82
- });
83
-
84
- afterAll(async () => {
85
- if (!tempDir) {
86
- return;
87
- }
88
- if (process.platform === "win32") {
89
- await fs.rm(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 });
90
- } else {
91
- await fs.rm(tempDir, { recursive: true, force: true });
92
- }
93
- });
94
-
95
- beforeEach(() => {
96
- spawnState.queue.length = 0;
97
- spawnState.spawn.mockReset();
98
- spawnState.spawn.mockImplementation(() => {
99
- const next = spawnState.queue.shift() ?? { stdout: "" };
100
- const stdout = new PassThrough();
101
- const stderr = new PassThrough();
102
- const child = new EventEmitter() as EventEmitter & {
103
- stdout: PassThrough;
104
- stderr: PassThrough;
105
- kill: (signal?: string) => boolean;
106
- };
107
- child.stdout = stdout;
108
- child.stderr = stderr;
109
- child.kill = () => true;
110
-
111
- setImmediate(() => {
112
- if (next.stderr) {
113
- stderr.end(next.stderr);
114
- } else {
115
- stderr.end();
116
- }
117
- stdout.end(next.stdout);
118
- child.emit("exit", next.exitCode ?? 0);
119
- });
120
-
121
- return child;
122
- });
123
- });
124
-
125
- const queueSuccessfulEnvelope = (hello = "world") => {
126
- spawnState.queue.push({
127
- stdout: JSON.stringify({
33
+ it("returns the Lobster envelope in details", async () => {
34
+ const runner = {
35
+ run: vi.fn().mockResolvedValue({
128
36
  ok: true,
129
37
  status: "ok",
130
- output: [{ hello }],
38
+ output: [{ hello: "world" }],
131
39
  requiresApproval: null,
132
40
  }),
41
+ };
42
+
43
+ const tool = createLobsterTool(fakeApi(), { runner });
44
+ const res = await tool.execute("call1", {
45
+ action: "run",
46
+ pipeline: "noop",
47
+ timeoutMs: 1000,
133
48
  });
134
- };
135
49
 
136
- it("runs lobster and returns parsed envelope in details", async () => {
137
- spawnState.queue.push({
138
- stdout: JSON.stringify({
50
+ expect(runner.run).toHaveBeenCalledWith({
51
+ action: "run",
52
+ pipeline: "noop",
53
+ cwd: process.cwd(),
54
+ timeoutMs: 1000,
55
+ maxStdoutBytes: 512_000,
56
+ });
57
+ expect(res.details).toMatchObject({
58
+ ok: true,
59
+ status: "ok",
60
+ output: [{ hello: "world" }],
61
+ requiresApproval: null,
62
+ });
63
+ });
64
+
65
+ it("supports approval envelopes without changing the tool contract", async () => {
66
+ const runner = {
67
+ run: vi.fn().mockResolvedValue({
139
68
  ok: true,
140
- status: "ok",
141
- output: [{ hello: "world" }],
142
- requiresApproval: null,
69
+ status: "needs_approval",
70
+ output: [],
71
+ requiresApproval: {
72
+ type: "approval_request",
73
+ prompt: "Send these alerts?",
74
+ items: [{ id: "alert-1" }],
75
+ resumeToken: "resume-token-1",
76
+ },
143
77
  }),
144
- });
78
+ };
145
79
 
146
- const tool = createLobsterTool(fakeApi());
147
- const res = await tool.execute("call1", {
80
+ const tool = createLobsterTool(fakeApi(), { runner });
81
+ const res = await tool.execute("call-injected-runner", {
148
82
  action: "run",
149
83
  pipeline: "noop",
150
- timeoutMs: 1000,
84
+ argsJson: '{"since_hours":1}',
85
+ timeoutMs: 1500,
86
+ maxStdoutBytes: 4096,
151
87
  });
152
88
 
153
- expect(spawnState.spawn).toHaveBeenCalled();
154
- expect(res.details).toMatchObject({ ok: true, status: "ok" });
89
+ expect(runner.run).toHaveBeenCalledWith({
90
+ action: "run",
91
+ pipeline: "noop",
92
+ argsJson: '{"since_hours":1}',
93
+ cwd: process.cwd(),
94
+ timeoutMs: 1500,
95
+ maxStdoutBytes: 4096,
96
+ });
97
+ expect(res.details).toMatchObject({
98
+ ok: true,
99
+ status: "needs_approval",
100
+ requiresApproval: {
101
+ type: "approval_request",
102
+ prompt: "Send these alerts?",
103
+ resumeToken: "resume-token-1",
104
+ },
105
+ });
155
106
  });
156
107
 
157
- it("tolerates noisy stdout before the JSON envelope", async () => {
158
- const payload = { ok: true, status: "ok", output: [], requiresApproval: null };
159
- spawnState.queue.push({
160
- stdout: `noise before json\n${JSON.stringify(payload)}`,
108
+ it("throws when the runner returns an error envelope", async () => {
109
+ const tool = createLobsterTool(fakeApi(), {
110
+ runner: {
111
+ run: vi.fn().mockResolvedValue({
112
+ ok: false,
113
+ error: {
114
+ type: "runtime_error",
115
+ message: "boom",
116
+ },
117
+ }),
118
+ },
161
119
  });
162
120
 
163
- const tool = createLobsterTool(fakeApi());
164
- const res = await tool.execute("call-noisy", {
121
+ await expect(
122
+ tool.execute("call-runner-error", {
123
+ action: "run",
124
+ pipeline: "noop",
125
+ }),
126
+ ).rejects.toThrow("boom");
127
+ });
128
+
129
+ it("can run through managed TaskFlow mode", async () => {
130
+ const runner = {
131
+ run: vi.fn().mockResolvedValue({
132
+ ok: true,
133
+ status: "needs_approval",
134
+ output: [],
135
+ requiresApproval: {
136
+ type: "approval_request",
137
+ prompt: "Approve this?",
138
+ items: [{ id: "item-1" }],
139
+ resumeToken: "resume-1",
140
+ approvalId: "approval-1",
141
+ },
142
+ }),
143
+ };
144
+ const taskFlow = createFakeTaskFlow();
145
+
146
+ const tool = createLobsterTool(fakeApi(), { runner, taskFlow });
147
+ const res = await tool.execute("call-managed-run", {
165
148
  action: "run",
166
149
  pipeline: "noop",
167
- timeoutMs: 1000,
150
+ flowControllerId: "tests/lobster",
151
+ flowGoal: "Run Lobster workflow",
152
+ flowStateJson: '{"lane":"email"}',
153
+ flowCurrentStep: "run_lobster",
154
+ flowWaitingStep: "await_review",
168
155
  });
169
156
 
170
- expect(res.details).toMatchObject({ ok: true, status: "ok" });
157
+ expect(taskFlow.createManaged).toHaveBeenCalledWith({
158
+ controllerId: "tests/lobster",
159
+ goal: "Run Lobster workflow",
160
+ currentStep: "run_lobster",
161
+ stateJson: { lane: "email" },
162
+ });
163
+ expect(taskFlow.setWaiting).toHaveBeenCalledWith({
164
+ flowId: "flow-1",
165
+ expectedRevision: 1,
166
+ currentStep: "await_review",
167
+ waitJson: {
168
+ kind: "lobster_approval",
169
+ prompt: "Approve this?",
170
+ items: [{ id: "item-1" }],
171
+ resumeToken: "resume-1",
172
+ approvalId: "approval-1",
173
+ },
174
+ });
175
+ expect(res.details).toMatchObject({
176
+ ok: true,
177
+ status: "needs_approval",
178
+ flow: {
179
+ flowId: "flow-1",
180
+ },
181
+ mutation: {
182
+ applied: true,
183
+ },
184
+ });
171
185
  });
172
186
 
173
- it("requires action", async () => {
174
- const tool = createLobsterTool(fakeApi());
175
- await expect(tool.execute("call-action-missing", {})).rejects.toThrow(/action required/);
187
+ it("rejects managed TaskFlow params when no bound taskFlow runtime is available", async () => {
188
+ const tool = createLobsterTool(fakeApi(), {
189
+ runner: { run: vi.fn() },
190
+ });
191
+
192
+ await expect(
193
+ tool.execute("call-missing-taskflow", {
194
+ action: "run",
195
+ pipeline: "noop",
196
+ flowControllerId: "tests/lobster",
197
+ flowGoal: "Run Lobster workflow",
198
+ }),
199
+ ).rejects.toThrow(/Managed TaskFlow run mode requires a bound taskFlow runtime/);
176
200
  });
177
201
 
178
- it("requires pipeline for run action", async () => {
179
- const tool = createLobsterTool(fakeApi());
202
+ it("rejects invalid flowStateJson in managed TaskFlow mode", async () => {
203
+ const tool = createLobsterTool(fakeApi(), {
204
+ runner: { run: vi.fn() },
205
+ taskFlow: createFakeTaskFlow(),
206
+ });
207
+
180
208
  await expect(
181
- tool.execute("call-pipeline-missing", {
209
+ tool.execute("call-invalid-flow-json", {
182
210
  action: "run",
211
+ pipeline: "noop",
212
+ flowControllerId: "tests/lobster",
213
+ flowGoal: "Run Lobster workflow",
214
+ flowStateJson: "{bad",
183
215
  }),
184
- ).rejects.toThrow(/pipeline required/);
216
+ ).rejects.toThrow(/flowStateJson must be valid JSON/);
185
217
  });
186
218
 
187
- it("requires token and approve for resume action", async () => {
188
- const tool = createLobsterTool(fakeApi());
219
+ it("can resume managed TaskFlow mode with only approvalId", async () => {
220
+ const runner = {
221
+ run: vi.fn().mockResolvedValue({
222
+ ok: true,
223
+ status: "ok",
224
+ output: [],
225
+ requiresApproval: null,
226
+ }),
227
+ };
228
+ const taskFlow = createFakeTaskFlow();
229
+ const tool = createLobsterTool(fakeApi(), { runner, taskFlow });
230
+
231
+ const res = await tool.execute("call-managed-resume-approval-id", {
232
+ action: "resume",
233
+ approvalId: "approval-1",
234
+ approve: true,
235
+ flowId: "flow-1",
236
+ flowExpectedRevision: 1,
237
+ flowCurrentStep: "resume_lobster",
238
+ });
239
+
240
+ expect(taskFlow.resume).toHaveBeenCalledWith({
241
+ flowId: "flow-1",
242
+ expectedRevision: 1,
243
+ status: "running",
244
+ currentStep: "resume_lobster",
245
+ });
246
+ expect(runner.run).toHaveBeenCalledWith({
247
+ action: "resume",
248
+ approvalId: "approval-1",
249
+ approve: true,
250
+ cwd: process.cwd(),
251
+ timeoutMs: 20_000,
252
+ maxStdoutBytes: 512_000,
253
+ });
254
+ expect(res.details).toMatchObject({
255
+ ok: true,
256
+ status: "ok",
257
+ mutation: {
258
+ applied: true,
259
+ },
260
+ });
261
+ });
262
+
263
+ it("rejects managed TaskFlow resume mode without a token or approvalId", async () => {
264
+ const tool = createLobsterTool(fakeApi(), {
265
+ runner: { run: vi.fn() },
266
+ taskFlow: createFakeTaskFlow(),
267
+ });
268
+
189
269
  await expect(
190
- tool.execute("call-resume-token-missing", {
270
+ tool.execute("call-missing-resume-token", {
191
271
  action: "resume",
272
+ flowId: "flow-1",
273
+ flowExpectedRevision: 1,
192
274
  approve: true,
193
275
  }),
194
- ).rejects.toThrow(/token required/);
276
+ ).rejects.toThrow(/token or approvalId required when using managed TaskFlow resume mode/);
277
+ });
278
+
279
+ it("rejects managed TaskFlow resume mode without approve", async () => {
280
+ const tool = createLobsterTool(fakeApi(), {
281
+ runner: { run: vi.fn() },
282
+ taskFlow: createFakeTaskFlow(),
283
+ });
284
+
195
285
  await expect(
196
- tool.execute("call-resume-approve-missing", {
286
+ tool.execute("call-missing-resume-approve", {
197
287
  action: "resume",
198
288
  token: "resume-token",
289
+ flowId: "flow-1",
290
+ flowExpectedRevision: 1,
199
291
  }),
200
- ).rejects.toThrow(/approve required/);
292
+ ).rejects.toThrow(/approve required when using managed TaskFlow resume mode/);
293
+ });
294
+
295
+ it("requires action", async () => {
296
+ const tool = createLobsterTool(fakeApi(), {
297
+ runner: { run: vi.fn() },
298
+ });
299
+ await expect(tool.execute("call-action-missing", {})).rejects.toThrow(/action required/);
201
300
  });
202
301
 
203
302
  it("rejects unknown action", async () => {
204
- const tool = createLobsterTool(fakeApi());
303
+ const tool = createLobsterTool(fakeApi(), {
304
+ runner: { run: vi.fn() },
305
+ });
205
306
  await expect(
206
307
  tool.execute("call-action-unknown", {
207
308
  action: "explode",
@@ -210,9 +311,11 @@ describe("lobster plugin tool", () => {
210
311
  });
211
312
 
212
313
  it("rejects absolute cwd", async () => {
213
- const tool = createLobsterTool(fakeApi());
314
+ const tool = createLobsterTool(fakeApi(), {
315
+ runner: { run: vi.fn() },
316
+ });
214
317
  await expect(
215
- tool.execute("call2c", {
318
+ tool.execute("call-absolute-cwd", {
216
319
  action: "run",
217
320
  pipeline: "noop",
218
321
  cwd: "/tmp",
@@ -221,9 +324,11 @@ describe("lobster plugin tool", () => {
221
324
  });
222
325
 
223
326
  it("rejects cwd that escapes the gateway working directory", async () => {
224
- const tool = createLobsterTool(fakeApi());
327
+ const tool = createLobsterTool(fakeApi(), {
328
+ runner: { run: vi.fn() },
329
+ });
225
330
  await expect(
226
- tool.execute("call2d", {
331
+ tool.execute("call-escape-cwd", {
227
332
  action: "run",
228
333
  pipeline: "noop",
229
334
  cwd: "../../etc",
@@ -231,78 +336,15 @@ describe("lobster plugin tool", () => {
231
336
  ).rejects.toThrow(/must stay within/);
232
337
  });
233
338
 
234
- it("rejects invalid JSON from lobster", async () => {
235
- spawnState.queue.push({ stdout: "nope" });
236
-
237
- const tool = createLobsterTool(fakeApi());
238
- await expect(
239
- tool.execute("call3", {
240
- action: "run",
241
- pipeline: "noop",
242
- }),
243
- ).rejects.toThrow(/invalid JSON/);
244
- });
245
-
246
- it("runs Windows cmd shims through Node without enabling shell", async () => {
247
- setProcessPlatform("win32");
248
- const shimScriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs");
249
- const shimPath = path.join(tempDir, "shim-bin", "lobster.cmd");
250
- await createWindowsCmdShimFixture({
251
- shimPath,
252
- scriptPath: shimScriptPath,
253
- shimLine: `"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`,
254
- });
255
- process.env.PATHEXT = ".CMD;.EXE";
256
- process.env.PATH = `${path.dirname(shimPath)};${process.env.PATH ?? ""}`;
257
- queueSuccessfulEnvelope();
258
-
259
- const tool = createLobsterTool(fakeApi());
260
- await tool.execute("call-win-shim", {
261
- action: "run",
262
- pipeline: "noop",
263
- });
264
-
265
- const [command, argv, options] = spawnState.spawn.mock.calls[0] ?? [];
266
- expect(command).toBe(process.execPath);
267
- expect(argv).toEqual([shimScriptPath, "run", "--mode", "tool", "noop"]);
268
- expect(options).toMatchObject({ windowsHide: true });
269
- expect(options).not.toHaveProperty("shell");
270
- });
271
-
272
- it("does not retry a failed Windows spawn with shell fallback", async () => {
273
- setProcessPlatform("win32");
274
- spawnState.spawn.mockReset();
275
- spawnState.spawn.mockImplementationOnce(() => {
276
- const child = new EventEmitter() as EventEmitter & {
277
- stdout: PassThrough;
278
- stderr: PassThrough;
279
- kill: (signal?: string) => boolean;
280
- };
281
- child.stdout = new PassThrough();
282
- child.stderr = new PassThrough();
283
- child.kill = () => true;
284
- const err = Object.assign(new Error("spawn failed"), { code: "ENOENT" });
285
- setImmediate(() => child.emit("error", err));
286
- return child;
287
- });
288
-
289
- const tool = createLobsterTool(fakeApi());
290
- await expect(
291
- tool.execute("call-win-no-retry", {
292
- action: "run",
293
- pipeline: "noop",
294
- }),
295
- ).rejects.toThrow(/spawn failed/);
296
- expect(spawnState.spawn).toHaveBeenCalledTimes(1);
297
- });
298
-
299
339
  it("can be gated off in sandboxed contexts", async () => {
300
340
  const api = fakeApi();
301
341
  const factoryTool = (ctx: OpenClawPluginToolContext) => {
302
342
  if (ctx.sandboxed) {
303
343
  return null;
304
344
  }
305
- return createLobsterTool(api);
345
+ return createLobsterTool(api, {
346
+ runner: { run: vi.fn() },
347
+ });
306
348
  };
307
349
 
308
350
  expect(factoryTool(fakeCtx({ sandboxed: true }))).toBeNull();