@openclaw/lobster 2026.5.2 → 2026.5.3-beta.2

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,572 +0,0 @@
1
- import fs from "node:fs/promises";
2
- import { createRequire } from "node:module";
3
- import os from "node:os";
4
- import path from "node:path";
5
- import { pathToFileURL } from "node:url";
6
- import { afterEach, describe, expect, it, vi } from "vitest";
7
- import {
8
- createEmbeddedLobsterRunner,
9
- loadEmbeddedToolRuntimeFromPackage,
10
- resolveLobsterCwd,
11
- } from "./lobster-runner.js";
12
-
13
- const requireForTest = createRequire(import.meta.url);
14
-
15
- type AjvCacheOwner = {
16
- _cache?: { size: number };
17
- };
18
-
19
- function readAjvInternalCacheSize(ajv: unknown): number {
20
- return (ajv as AjvCacheOwner)._cache?.size ?? 0;
21
- }
22
-
23
- function createRepeatedResponseSchema() {
24
- return {
25
- type: "object",
26
- properties: {
27
- answer: { type: "string" },
28
- },
29
- required: ["answer"],
30
- additionalProperties: false,
31
- };
32
- }
33
-
34
- function createUniqueResponseSchema(index: number) {
35
- return {
36
- type: "object",
37
- properties: {
38
- [`answer${index}`]: { type: "string" },
39
- },
40
- required: [`answer${index}`],
41
- additionalProperties: false,
42
- };
43
- }
44
-
45
- describe("resolveLobsterCwd", () => {
46
- it("defaults to the current working directory", () => {
47
- expect(resolveLobsterCwd(undefined)).toBe(process.cwd());
48
- });
49
-
50
- it("keeps relative paths inside the repo root", () => {
51
- expect(resolveLobsterCwd("extensions/lobster")).toBe(
52
- path.resolve(process.cwd(), "extensions/lobster"),
53
- );
54
- });
55
- });
56
-
57
- describe("createEmbeddedLobsterRunner", () => {
58
- afterEach(() => {
59
- vi.restoreAllMocks();
60
- });
61
-
62
- it("runs inline pipelines through the embedded runtime", async () => {
63
- const runtime = {
64
- runToolRequest: vi.fn().mockResolvedValue({
65
- ok: true,
66
- protocolVersion: 1,
67
- status: "ok",
68
- output: [{ hello: "world" }],
69
- requiresApproval: null,
70
- }),
71
- resumeToolRequest: vi.fn(),
72
- };
73
-
74
- const runner = createEmbeddedLobsterRunner({
75
- loadRuntime: vi.fn().mockResolvedValue(runtime),
76
- });
77
-
78
- const envelope = await runner.run({
79
- action: "run",
80
- pipeline: "exec --json=true echo hi",
81
- cwd: process.cwd(),
82
- timeoutMs: 2000,
83
- maxStdoutBytes: 4096,
84
- });
85
-
86
- expect(runtime.runToolRequest).toHaveBeenCalledTimes(1);
87
- expect(runtime.runToolRequest).toHaveBeenCalledWith({
88
- pipeline: "exec --json=true echo hi",
89
- ctx: expect.objectContaining({
90
- cwd: process.cwd(),
91
- mode: "tool",
92
- signal: expect.any(AbortSignal),
93
- }),
94
- });
95
- expect(envelope).toEqual({
96
- ok: true,
97
- status: "ok",
98
- output: [{ hello: "world" }],
99
- requiresApproval: null,
100
- });
101
- });
102
-
103
- it("detects workflow files and parses argsJson", async () => {
104
- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-runner-"));
105
- const workflowPath = path.join(tempDir, "workflow.lobster");
106
- await fs.writeFile(workflowPath, "steps: []\n", "utf8");
107
-
108
- try {
109
- const runtime = {
110
- runToolRequest: vi.fn().mockResolvedValue({
111
- ok: true,
112
- protocolVersion: 1,
113
- status: "ok",
114
- output: [],
115
- requiresApproval: null,
116
- }),
117
- resumeToolRequest: vi.fn(),
118
- };
119
-
120
- const runner = createEmbeddedLobsterRunner({
121
- loadRuntime: vi.fn().mockResolvedValue(runtime),
122
- });
123
-
124
- await runner.run({
125
- action: "run",
126
- pipeline: "workflow.lobster",
127
- argsJson: '{"limit":3}',
128
- cwd: tempDir,
129
- timeoutMs: 2000,
130
- maxStdoutBytes: 4096,
131
- });
132
-
133
- expect(runtime.runToolRequest).toHaveBeenCalledWith({
134
- filePath: workflowPath,
135
- args: { limit: 3 },
136
- ctx: expect.objectContaining({
137
- cwd: tempDir,
138
- mode: "tool",
139
- }),
140
- });
141
- } finally {
142
- await fs.rm(tempDir, { recursive: true, force: true });
143
- }
144
- });
145
-
146
- it("returns a parse error when workflow args are invalid JSON", async () => {
147
- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-runner-"));
148
- const workflowPath = path.join(tempDir, "workflow.lobster");
149
- await fs.writeFile(workflowPath, "steps: []\n", "utf8");
150
-
151
- try {
152
- const runtime = {
153
- runToolRequest: vi.fn(),
154
- resumeToolRequest: vi.fn(),
155
- };
156
- const runner = createEmbeddedLobsterRunner({
157
- loadRuntime: vi.fn().mockResolvedValue(runtime),
158
- });
159
-
160
- await expect(
161
- runner.run({
162
- action: "run",
163
- pipeline: "workflow.lobster",
164
- argsJson: "{bad",
165
- cwd: tempDir,
166
- timeoutMs: 2000,
167
- maxStdoutBytes: 4096,
168
- }),
169
- ).rejects.toThrow("run --args-json must be valid JSON");
170
- expect(runtime.runToolRequest).not.toHaveBeenCalled();
171
- } finally {
172
- await fs.rm(tempDir, { recursive: true, force: true });
173
- }
174
- });
175
-
176
- it("throws when the embedded runtime returns an error envelope", async () => {
177
- const runtime = {
178
- runToolRequest: vi.fn().mockResolvedValue({
179
- ok: false,
180
- protocolVersion: 1,
181
- error: {
182
- type: "runtime_error",
183
- message: "boom",
184
- },
185
- }),
186
- resumeToolRequest: vi.fn(),
187
- };
188
-
189
- const runner = createEmbeddedLobsterRunner({
190
- loadRuntime: vi.fn().mockResolvedValue(runtime),
191
- });
192
-
193
- await expect(
194
- runner.run({
195
- action: "run",
196
- pipeline: "exec --json=true echo hi",
197
- cwd: process.cwd(),
198
- timeoutMs: 2000,
199
- maxStdoutBytes: 4096,
200
- }),
201
- ).rejects.toThrow("boom");
202
- });
203
-
204
- it("fails closed when the embedded runtime requests unsupported input", async () => {
205
- const runtime = {
206
- runToolRequest: vi.fn().mockResolvedValue({
207
- ok: true,
208
- protocolVersion: 1,
209
- status: "needs_input",
210
- output: [],
211
- requiresApproval: null,
212
- requiresInput: {
213
- prompt: "Need more data",
214
- schema: { type: "string" },
215
- },
216
- }),
217
- resumeToolRequest: vi.fn(),
218
- };
219
-
220
- const runner = createEmbeddedLobsterRunner({
221
- loadRuntime: vi.fn().mockResolvedValue(runtime),
222
- });
223
-
224
- await expect(
225
- runner.run({
226
- action: "run",
227
- pipeline: "exec --json=true echo hi",
228
- cwd: process.cwd(),
229
- timeoutMs: 2000,
230
- maxStdoutBytes: 4096,
231
- }),
232
- ).rejects.toThrow("Lobster input requests are not supported by the OpenClaw Lobster tool yet");
233
- });
234
-
235
- it("routes resume through the embedded runtime", async () => {
236
- const runtime = {
237
- runToolRequest: vi.fn(),
238
- resumeToolRequest: vi.fn().mockResolvedValue({
239
- ok: true,
240
- protocolVersion: 1,
241
- status: "cancelled",
242
- output: [],
243
- requiresApproval: null,
244
- }),
245
- };
246
-
247
- const runner = createEmbeddedLobsterRunner({
248
- loadRuntime: vi.fn().mockResolvedValue(runtime),
249
- });
250
-
251
- const envelope = await runner.run({
252
- action: "resume",
253
- token: "resume-token",
254
- approve: false,
255
- cwd: process.cwd(),
256
- timeoutMs: 2000,
257
- maxStdoutBytes: 4096,
258
- });
259
-
260
- expect(runtime.resumeToolRequest).toHaveBeenCalledWith({
261
- token: "resume-token",
262
- approved: false,
263
- ctx: expect.objectContaining({
264
- cwd: process.cwd(),
265
- mode: "tool",
266
- signal: expect.any(AbortSignal),
267
- }),
268
- });
269
- expect(envelope).toEqual({
270
- ok: true,
271
- status: "cancelled",
272
- output: [],
273
- requiresApproval: null,
274
- });
275
- });
276
-
277
- it("forwards approvalId through resume when token is absent", async () => {
278
- const runtime = {
279
- runToolRequest: vi.fn(),
280
- resumeToolRequest: vi.fn().mockResolvedValue({
281
- ok: true,
282
- protocolVersion: 1,
283
- status: "ok",
284
- output: [],
285
- requiresApproval: null,
286
- }),
287
- };
288
-
289
- const runner = createEmbeddedLobsterRunner({
290
- loadRuntime: vi.fn().mockResolvedValue(runtime),
291
- });
292
-
293
- await runner.run({
294
- action: "resume",
295
- approvalId: "dbc98d05",
296
- approve: true,
297
- cwd: process.cwd(),
298
- timeoutMs: 2000,
299
- maxStdoutBytes: 4096,
300
- });
301
-
302
- expect(runtime.resumeToolRequest).toHaveBeenCalledWith({
303
- approvalId: "dbc98d05",
304
- approved: true,
305
- ctx: expect.objectContaining({ mode: "tool" }),
306
- });
307
- });
308
-
309
- it("passes approvalId through the normalized needs_approval envelope", async () => {
310
- const runtime = {
311
- runToolRequest: vi.fn().mockResolvedValue({
312
- ok: true,
313
- protocolVersion: 1,
314
- status: "needs_approval",
315
- output: [],
316
- requiresApproval: {
317
- type: "approval_request",
318
- prompt: "ok?",
319
- items: [],
320
- resumeToken: "eyJ...",
321
- approvalId: "dbc98d05",
322
- },
323
- }),
324
- resumeToolRequest: vi.fn(),
325
- };
326
-
327
- const runner = createEmbeddedLobsterRunner({
328
- loadRuntime: vi.fn().mockResolvedValue(runtime),
329
- });
330
-
331
- const envelope = await runner.run({
332
- action: "run",
333
- pipeline: "exec --json=true echo hi",
334
- cwd: process.cwd(),
335
- timeoutMs: 2000,
336
- maxStdoutBytes: 4096,
337
- });
338
-
339
- expect(envelope).toEqual({
340
- ok: true,
341
- status: "needs_approval",
342
- output: [],
343
- requiresApproval: {
344
- type: "approval_request",
345
- prompt: "ok?",
346
- items: [],
347
- resumeToken: "eyJ...",
348
- approvalId: "dbc98d05",
349
- },
350
- });
351
- });
352
-
353
- it("loads the embedded runtime once per runner", async () => {
354
- const runtime = {
355
- runToolRequest: vi.fn().mockResolvedValue({
356
- ok: true,
357
- protocolVersion: 1,
358
- status: "ok",
359
- output: [],
360
- requiresApproval: null,
361
- }),
362
- resumeToolRequest: vi.fn().mockResolvedValue({
363
- ok: true,
364
- protocolVersion: 1,
365
- status: "cancelled",
366
- output: [],
367
- requiresApproval: null,
368
- }),
369
- };
370
- const loadRuntime = vi.fn().mockResolvedValue(runtime);
371
-
372
- const runner = createEmbeddedLobsterRunner({ loadRuntime });
373
-
374
- await runner.run({
375
- action: "run",
376
- pipeline: "exec --json=true echo hi",
377
- cwd: process.cwd(),
378
- timeoutMs: 2000,
379
- maxStdoutBytes: 4096,
380
- });
381
- await runner.run({
382
- action: "resume",
383
- token: "resume-token",
384
- approve: false,
385
- cwd: process.cwd(),
386
- timeoutMs: 2000,
387
- maxStdoutBytes: 4096,
388
- });
389
-
390
- expect(loadRuntime).toHaveBeenCalledTimes(1);
391
- });
392
-
393
- it("installs an Ajv content cache before loading the embedded runtime", async () => {
394
- const AjvModule = await import("ajv");
395
- const AjvCtor = AjvModule.default as unknown as new (opts?: object) => import("ajv").default;
396
- const ajv = new AjvCtor({ allErrors: true, strict: false, addUsedSchema: false });
397
- const before = readAjvInternalCacheSize(ajv);
398
-
399
- await loadEmbeddedToolRuntimeFromPackage({
400
- importModule: async () => ({
401
- runToolRequest: vi.fn(),
402
- resumeToolRequest: vi.fn(),
403
- }),
404
- });
405
-
406
- const first = ajv.compile(createRepeatedResponseSchema());
407
- const second = ajv.compile(createRepeatedResponseSchema());
408
- const afterRepeated = readAjvInternalCacheSize(ajv);
409
-
410
- expect(second).toBe(first);
411
- expect(afterRepeated - before).toBe(1);
412
-
413
- for (let index = 0; index < 520; index += 1) {
414
- ajv.compile(createUniqueResponseSchema(index));
415
- }
416
-
417
- expect(readAjvInternalCacheSize(ajv)).toBeLessThanOrEqual(before + 512);
418
- });
419
-
420
- it("deduplicates content-identical schema compilation in the installed Lobster runtime", async () => {
421
- await loadEmbeddedToolRuntimeFromPackage();
422
-
423
- const corePath = requireForTest.resolve("@clawdbot/lobster/core");
424
- const validationPath = path.join(path.dirname(path.dirname(corePath)), "validation.js");
425
- const validationModule = (await import(pathToFileURL(validationPath).href)) as {
426
- sharedAjv: import("ajv").default;
427
- };
428
- const before = readAjvInternalCacheSize(validationModule.sharedAjv);
429
-
430
- const first = validationModule.sharedAjv.compile(createRepeatedResponseSchema());
431
- for (let index = 0; index < 1000; index += 1) {
432
- validationModule.sharedAjv.compile(createRepeatedResponseSchema());
433
- }
434
- const second = validationModule.sharedAjv.compile(createRepeatedResponseSchema());
435
-
436
- expect(second).toBe(first);
437
- expect(readAjvInternalCacheSize(validationModule.sharedAjv) - before).toBe(1);
438
- });
439
-
440
- it("falls back to the installed package core file when the core export is unavailable", async () => {
441
- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-package-"));
442
- const packageRoot = path.join(tempDir, "node_modules", "@clawdbot", "lobster");
443
- const packageEntryPath = path.join(packageRoot, "dist", "src", "sdk", "index.js");
444
- const packageCorePath = path.join(packageRoot, "dist", "src", "core", "index.js");
445
-
446
- try {
447
- await fs.mkdir(path.dirname(packageEntryPath), { recursive: true });
448
- await fs.mkdir(path.dirname(packageCorePath), { recursive: true });
449
- await fs.writeFile(
450
- path.join(packageRoot, "package.json"),
451
- JSON.stringify({
452
- name: "@clawdbot/lobster",
453
- type: "module",
454
- main: "./dist/src/sdk/index.js",
455
- }),
456
- "utf8",
457
- );
458
- await fs.writeFile(packageEntryPath, "export {};\n", "utf8");
459
- await fs.writeFile(
460
- packageCorePath,
461
- [
462
- "export async function runToolRequest() {",
463
- " return { ok: true, status: 'ok', output: [{ source: 'fallback' }], requiresApproval: null };",
464
- "}",
465
- "export async function resumeToolRequest() {",
466
- " return { ok: true, status: 'cancelled', output: [], requiresApproval: null };",
467
- "}",
468
- "",
469
- ].join("\n"),
470
- "utf8",
471
- );
472
-
473
- const runtime = await loadEmbeddedToolRuntimeFromPackage({
474
- importModule: async (specifier) => {
475
- if (specifier === "@clawdbot/lobster/core") {
476
- throw new Error("package export missing");
477
- }
478
- return (await import(`${specifier}?t=${Date.now()}`)) as object;
479
- },
480
- resolvePackageEntry: () => packageEntryPath,
481
- });
482
-
483
- await expect(runtime.runToolRequest({ pipeline: "commands.list" })).resolves.toEqual({
484
- ok: true,
485
- status: "ok",
486
- output: [{ source: "fallback" }],
487
- requiresApproval: null,
488
- });
489
- } finally {
490
- await fs.rm(tempDir, { recursive: true, force: true });
491
- }
492
- });
493
-
494
- it("requires a pipeline for run", async () => {
495
- const runner = createEmbeddedLobsterRunner({
496
- loadRuntime: vi.fn().mockResolvedValue({
497
- runToolRequest: vi.fn(),
498
- resumeToolRequest: vi.fn(),
499
- }),
500
- });
501
-
502
- await expect(
503
- runner.run({
504
- action: "run",
505
- cwd: process.cwd(),
506
- timeoutMs: 2000,
507
- maxStdoutBytes: 4096,
508
- }),
509
- ).rejects.toThrow(/pipeline required/);
510
- });
511
-
512
- it("requires token and approve for resume", async () => {
513
- const runner = createEmbeddedLobsterRunner({
514
- loadRuntime: vi.fn().mockResolvedValue({
515
- runToolRequest: vi.fn(),
516
- resumeToolRequest: vi.fn(),
517
- }),
518
- });
519
-
520
- await expect(
521
- runner.run({
522
- action: "resume",
523
- approve: true,
524
- cwd: process.cwd(),
525
- timeoutMs: 2000,
526
- maxStdoutBytes: 4096,
527
- }),
528
- ).rejects.toThrow(/token or approvalId required/);
529
-
530
- await expect(
531
- runner.run({
532
- action: "resume",
533
- token: "resume-token",
534
- cwd: process.cwd(),
535
- timeoutMs: 2000,
536
- maxStdoutBytes: 4096,
537
- }),
538
- ).rejects.toThrow(/approve required/);
539
- });
540
-
541
- it("aborts long-running embedded work", async () => {
542
- const runtime = {
543
- runToolRequest: vi.fn(
544
- async ({ ctx }: { ctx?: { signal?: AbortSignal } }) =>
545
- await new Promise((resolve, reject) => {
546
- ctx?.signal?.addEventListener("abort", () => {
547
- reject(ctx.signal?.reason ?? new Error("aborted"));
548
- });
549
- setTimeout(
550
- () => resolve({ ok: true, status: "ok", output: [], requiresApproval: null }),
551
- 500,
552
- );
553
- }),
554
- ),
555
- resumeToolRequest: vi.fn(),
556
- };
557
-
558
- const runner = createEmbeddedLobsterRunner({
559
- loadRuntime: vi.fn().mockResolvedValue(runtime),
560
- });
561
-
562
- await expect(
563
- runner.run({
564
- action: "run",
565
- pipeline: "exec --json=true echo hi",
566
- cwd: process.cwd(),
567
- timeoutMs: 200,
568
- maxStdoutBytes: 4096,
569
- }),
570
- ).rejects.toThrow(/timed out|aborted/);
571
- });
572
- });