@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,395 +0,0 @@
1
- import { readFileSync } from "node:fs";
2
- import { stat } from "node:fs/promises";
3
- import { createRequire } from "node:module";
4
- import path from "node:path";
5
- import { Readable, Writable } from "node:stream";
6
- import { pathToFileURL } from "node:url";
7
- import { installLobsterAjvCompileCache } from "./lobster-ajv-cache.js";
8
-
9
- export type LobsterEnvelope =
10
- | {
11
- ok: true;
12
- status: "ok" | "needs_approval" | "cancelled";
13
- output: unknown[];
14
- requiresApproval: null | {
15
- type: "approval_request";
16
- prompt: string;
17
- items: unknown[];
18
- resumeToken?: string;
19
- approvalId?: string;
20
- };
21
- }
22
- | {
23
- ok: false;
24
- error: { type?: string; message: string };
25
- };
26
-
27
- export type LobsterRunnerParams = {
28
- action: "run" | "resume";
29
- pipeline?: string;
30
- argsJson?: string;
31
- token?: string;
32
- approvalId?: string;
33
- approve?: boolean;
34
- cwd: string;
35
- timeoutMs: number;
36
- maxStdoutBytes: number;
37
- };
38
-
39
- export type LobsterRunner = {
40
- run: (params: LobsterRunnerParams) => Promise<LobsterEnvelope>;
41
- };
42
-
43
- type EmbeddedToolContext = {
44
- cwd?: string;
45
- env?: Record<string, string | undefined>;
46
- mode?: "tool" | "human" | "sdk";
47
- stdin?: NodeJS.ReadableStream;
48
- stdout?: NodeJS.WritableStream;
49
- stderr?: NodeJS.WritableStream;
50
- signal?: AbortSignal;
51
- registry?: unknown;
52
- llmAdapters?: Record<string, unknown>;
53
- };
54
-
55
- type EmbeddedToolEnvelope = {
56
- protocolVersion?: number;
57
- ok: boolean;
58
- status?: "ok" | "needs_approval" | "needs_input" | "cancelled";
59
- output?: unknown[];
60
- requiresApproval?: {
61
- type?: "approval_request";
62
- prompt: string;
63
- items: unknown[];
64
- preview?: string;
65
- resumeToken?: string;
66
- approvalId?: string;
67
- } | null;
68
- requiresInput?: {
69
- prompt: string;
70
- schema?: unknown;
71
- items?: unknown[];
72
- resumeToken?: string;
73
- approvalId?: string;
74
- } | null;
75
- error?: {
76
- type?: string;
77
- message: string;
78
- };
79
- };
80
-
81
- type EmbeddedToolRuntime = {
82
- runToolRequest: (params: {
83
- pipeline?: string;
84
- filePath?: string;
85
- args?: Record<string, unknown>;
86
- ctx?: EmbeddedToolContext;
87
- }) => Promise<EmbeddedToolEnvelope>;
88
- resumeToolRequest: (params: {
89
- token?: string;
90
- approvalId?: string;
91
- approved?: boolean;
92
- response?: unknown;
93
- cancel?: boolean;
94
- ctx?: EmbeddedToolContext;
95
- }) => Promise<EmbeddedToolEnvelope>;
96
- };
97
-
98
- type LoadEmbeddedToolRuntime = () => Promise<EmbeddedToolRuntime>;
99
-
100
- type LoadEmbeddedToolRuntimeFromPackageOptions = {
101
- importModule?: (specifier: string) => Promise<Partial<EmbeddedToolRuntime>>;
102
- resolvePackageEntry?: (specifier: string) => string;
103
- };
104
-
105
- const lobsterRequire = createRequire(import.meta.url);
106
-
107
- function toEmbeddedToolRuntime(
108
- moduleExports: Partial<EmbeddedToolRuntime>,
109
- source: string,
110
- ): EmbeddedToolRuntime {
111
- const { runToolRequest, resumeToolRequest } = moduleExports;
112
- if (typeof runToolRequest === "function" && typeof resumeToolRequest === "function") {
113
- return { runToolRequest, resumeToolRequest };
114
- }
115
- throw new Error(`${source} does not export Lobster embedded runtime functions`);
116
- }
117
-
118
- function findLobsterPackageRoot(resolvedEntryPath: string): string {
119
- let dir = path.dirname(resolvedEntryPath);
120
- while (true) {
121
- const packageJsonPath = path.join(dir, "package.json");
122
- try {
123
- const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name?: string };
124
- if (parsed.name === "@clawdbot/lobster") {
125
- return dir;
126
- }
127
- } catch {
128
- // Keep walking until the installed package root is found.
129
- }
130
-
131
- const parent = path.dirname(dir);
132
- if (parent === dir) {
133
- throw new Error(`Could not locate @clawdbot/lobster package root from ${resolvedEntryPath}`);
134
- }
135
- dir = parent;
136
- }
137
- }
138
-
139
- function normalizeForCwdSandbox(p: string): string {
140
- const normalized = path.normalize(p);
141
- return process.platform === "win32" ? normalized.toLowerCase() : normalized;
142
- }
143
-
144
- export function resolveLobsterCwd(cwdRaw: unknown): string {
145
- if (typeof cwdRaw !== "string" || !cwdRaw.trim()) {
146
- return process.cwd();
147
- }
148
- const cwd = cwdRaw.trim();
149
- if (path.isAbsolute(cwd)) {
150
- throw new Error("cwd must be a relative path");
151
- }
152
- const base = process.cwd();
153
- const resolved = path.resolve(base, cwd);
154
-
155
- const rel = path.relative(normalizeForCwdSandbox(base), normalizeForCwdSandbox(resolved));
156
- if (rel === "" || rel === ".") {
157
- return resolved;
158
- }
159
- if (rel.startsWith("..") || path.isAbsolute(rel)) {
160
- throw new Error("cwd must stay within the gateway working directory");
161
- }
162
- return resolved;
163
- }
164
-
165
- function createLimitedSink(maxBytes: number, label: "stdout" | "stderr") {
166
- let bytes = 0;
167
- return new Writable({
168
- write(chunk, _encoding, callback) {
169
- bytes += Buffer.byteLength(String(chunk), "utf8");
170
- if (bytes > maxBytes) {
171
- callback(new Error(`lobster ${label} exceeded maxStdoutBytes`));
172
- return;
173
- }
174
- callback();
175
- },
176
- });
177
- }
178
-
179
- function normalizeEnvelope(envelope: EmbeddedToolEnvelope): LobsterEnvelope {
180
- if (envelope.ok) {
181
- if (envelope.status === "needs_input") {
182
- return {
183
- ok: false,
184
- error: {
185
- type: "unsupported_status",
186
- message: "Lobster input requests are not supported by the OpenClaw Lobster tool yet",
187
- },
188
- };
189
- }
190
- return {
191
- ok: true,
192
- status: envelope.status ?? "ok",
193
- output: Array.isArray(envelope.output) ? envelope.output : [],
194
- requiresApproval: envelope.requiresApproval
195
- ? {
196
- type: "approval_request",
197
- prompt: envelope.requiresApproval.prompt,
198
- items: envelope.requiresApproval.items,
199
- ...(envelope.requiresApproval.resumeToken
200
- ? { resumeToken: envelope.requiresApproval.resumeToken }
201
- : {}),
202
- ...(envelope.requiresApproval.approvalId
203
- ? { approvalId: envelope.requiresApproval.approvalId }
204
- : {}),
205
- }
206
- : null,
207
- };
208
- }
209
- return {
210
- ok: false,
211
- error: {
212
- type: envelope.error?.type,
213
- message: envelope.error?.message ?? "lobster runtime failed",
214
- },
215
- };
216
- }
217
-
218
- function throwOnErrorEnvelope(envelope: LobsterEnvelope): Extract<LobsterEnvelope, { ok: true }> {
219
- if (envelope.ok) {
220
- return envelope;
221
- }
222
- throw new Error(envelope.error.message);
223
- }
224
-
225
- async function resolveWorkflowFile(candidate: string, cwd: string) {
226
- const resolved = path.isAbsolute(candidate) ? candidate : path.resolve(cwd, candidate);
227
- const fileStat = await stat(resolved);
228
- if (!fileStat.isFile()) {
229
- throw new Error("Workflow path is not a file");
230
- }
231
- const ext = path.extname(resolved).toLowerCase();
232
- if (![".lobster", ".yaml", ".yml", ".json"].includes(ext)) {
233
- throw new Error("Workflow file must end in .lobster, .yaml, .yml, or .json");
234
- }
235
- return resolved;
236
- }
237
-
238
- async function detectWorkflowFile(candidate: string, cwd: string) {
239
- const trimmed = candidate.trim();
240
- if (!trimmed || trimmed.includes("|")) {
241
- return null;
242
- }
243
- try {
244
- return await resolveWorkflowFile(trimmed, cwd);
245
- } catch {
246
- return null;
247
- }
248
- }
249
-
250
- function parseWorkflowArgs(argsJson: string) {
251
- return JSON.parse(argsJson) as Record<string, unknown>;
252
- }
253
-
254
- function createEmbeddedToolContext(
255
- params: LobsterRunnerParams,
256
- signal?: AbortSignal,
257
- ): EmbeddedToolContext {
258
- const env = { ...process.env } as Record<string, string | undefined>;
259
- return {
260
- cwd: params.cwd,
261
- env,
262
- mode: "tool",
263
- stdin: Readable.from([]),
264
- stdout: createLimitedSink(Math.max(1024, params.maxStdoutBytes), "stdout"),
265
- stderr: createLimitedSink(Math.max(1024, params.maxStdoutBytes), "stderr"),
266
- signal,
267
- };
268
- }
269
-
270
- async function withTimeout<T>(
271
- timeoutMs: number,
272
- fn: (signal?: AbortSignal) => Promise<T>,
273
- ): Promise<T> {
274
- const timeout = Math.max(200, timeoutMs);
275
- const controller = new AbortController();
276
- return await new Promise<T>((resolve, reject) => {
277
- const onTimeout = () => {
278
- const error = new Error("lobster runtime timed out");
279
- controller.abort(error);
280
- reject(error);
281
- };
282
-
283
- const timer = setTimeout(onTimeout, timeout);
284
- void fn(controller.signal).then(
285
- (value) => {
286
- clearTimeout(timer);
287
- resolve(value);
288
- },
289
- (error) => {
290
- clearTimeout(timer);
291
- reject(error);
292
- },
293
- );
294
- });
295
- }
296
-
297
- export async function loadEmbeddedToolRuntimeFromPackage(
298
- options: LoadEmbeddedToolRuntimeFromPackageOptions = {},
299
- ): Promise<EmbeddedToolRuntime> {
300
- installLobsterAjvCompileCache();
301
-
302
- const importModule =
303
- options.importModule ??
304
- (async (specifier: string) => (await import(specifier)) as Partial<EmbeddedToolRuntime>);
305
- const resolvePackageEntry =
306
- options.resolvePackageEntry ?? ((specifier: string) => lobsterRequire.resolve(specifier));
307
-
308
- let coreLoadError: unknown;
309
- try {
310
- const coreSpecifier = ["@clawdbot", "lobster", "core"].join("/");
311
- return toEmbeddedToolRuntime(await importModule(coreSpecifier), "@clawdbot/lobster/core");
312
- } catch (error) {
313
- coreLoadError = error;
314
- }
315
-
316
- let fallbackLoadError: unknown;
317
- try {
318
- const packageEntryPath = resolvePackageEntry("@clawdbot/lobster");
319
- const packageRoot = findLobsterPackageRoot(packageEntryPath);
320
- const coreRuntimeUrl = pathToFileURL(path.join(packageRoot, "dist/src/core/index.js")).href;
321
- return toEmbeddedToolRuntime(await importModule(coreRuntimeUrl), coreRuntimeUrl);
322
- } catch (error) {
323
- fallbackLoadError = error;
324
- }
325
-
326
- throw new Error("Failed to load the Lobster embedded runtime", {
327
- cause: new AggregateError(
328
- [coreLoadError, fallbackLoadError],
329
- "Both Lobster embedded runtime load paths failed",
330
- ),
331
- });
332
- }
333
-
334
- export function createEmbeddedLobsterRunner(options?: {
335
- loadRuntime?: LoadEmbeddedToolRuntime;
336
- }): LobsterRunner {
337
- const loadRuntime = options?.loadRuntime ?? loadEmbeddedToolRuntimeFromPackage;
338
- let runtimePromise: Promise<EmbeddedToolRuntime> | undefined;
339
- return {
340
- async run(params) {
341
- runtimePromise ??= loadRuntime();
342
- const runtime = await runtimePromise;
343
- return await withTimeout(params.timeoutMs, async (signal) => {
344
- const ctx = createEmbeddedToolContext(params, signal);
345
-
346
- if (params.action === "run") {
347
- const pipeline = params.pipeline?.trim() ?? "";
348
- if (!pipeline) {
349
- throw new Error("pipeline required");
350
- }
351
-
352
- const filePath = await detectWorkflowFile(pipeline, params.cwd);
353
- if (filePath) {
354
- const parsedArgsJson = params.argsJson?.trim() ?? "";
355
- let args: Record<string, unknown> | undefined;
356
- if (parsedArgsJson) {
357
- try {
358
- args = parseWorkflowArgs(parsedArgsJson);
359
- } catch {
360
- throw new Error("run --args-json must be valid JSON");
361
- }
362
- }
363
- return throwOnErrorEnvelope(
364
- normalizeEnvelope(await runtime.runToolRequest({ filePath, args, ctx })),
365
- );
366
- }
367
-
368
- return throwOnErrorEnvelope(
369
- normalizeEnvelope(await runtime.runToolRequest({ pipeline, ctx })),
370
- );
371
- }
372
-
373
- const token = params.token?.trim() ?? "";
374
- const approvalId = params.approvalId?.trim() ?? "";
375
- if (!token && !approvalId) {
376
- throw new Error("token or approvalId required");
377
- }
378
- if (typeof params.approve !== "boolean") {
379
- throw new Error("approve required");
380
- }
381
-
382
- return throwOnErrorEnvelope(
383
- normalizeEnvelope(
384
- await runtime.resumeToolRequest({
385
- ...(token ? { token } : {}),
386
- ...(approvalId ? { approvalId } : {}),
387
- approved: params.approve,
388
- ctx,
389
- }),
390
- ),
391
- );
392
- });
393
- },
394
- };
395
- }
@@ -1,227 +0,0 @@
1
- import { describe, expect, it, vi } from "vitest";
2
- import type { LobsterRunner } from "./lobster-runner.js";
3
- import { resumeManagedLobsterFlow, runManagedLobsterFlow } from "./lobster-taskflow.js";
4
- import { createFakeTaskFlow } from "./taskflow-test-helpers.js";
5
-
6
- function expectManagedFlowFailure(
7
- result: Awaited<ReturnType<typeof runManagedLobsterFlow | typeof resumeManagedLobsterFlow>>,
8
- ) {
9
- expect(result.ok).toBe(false);
10
- if (result.ok) {
11
- throw new Error("Expected managed Lobster flow to fail");
12
- }
13
- return result;
14
- }
15
- function createRunner(result: Awaited<ReturnType<LobsterRunner["run"]>>): LobsterRunner {
16
- return {
17
- run: vi.fn().mockResolvedValue(result),
18
- };
19
- }
20
-
21
- function createRunFlowParams(
22
- taskFlow: ReturnType<typeof createFakeTaskFlow>,
23
- runner: LobsterRunner,
24
- ): Parameters<typeof runManagedLobsterFlow>[0] {
25
- return {
26
- taskFlow,
27
- runner,
28
- runnerParams: {
29
- action: "run",
30
- pipeline: "noop",
31
- cwd: process.cwd(),
32
- timeoutMs: 1000,
33
- maxStdoutBytes: 4096,
34
- },
35
- controllerId: "tests/lobster",
36
- goal: "Run Lobster workflow",
37
- };
38
- }
39
-
40
- function createResumeFlowParams(
41
- taskFlow: ReturnType<typeof createFakeTaskFlow>,
42
- runner: LobsterRunner,
43
- ): Parameters<typeof resumeManagedLobsterFlow>[0] {
44
- return {
45
- taskFlow,
46
- runner,
47
- flowId: "flow-1",
48
- expectedRevision: 4,
49
- runnerParams: {
50
- action: "resume",
51
- token: "resume-1",
52
- approve: true,
53
- cwd: process.cwd(),
54
- timeoutMs: 1000,
55
- maxStdoutBytes: 4096,
56
- },
57
- };
58
- }
59
-
60
- describe("runManagedLobsterFlow", () => {
61
- it("creates a flow and finishes it when Lobster succeeds", async () => {
62
- const taskFlow = createFakeTaskFlow();
63
- const runner = createRunner({
64
- ok: true,
65
- status: "ok",
66
- output: [{ id: "result-1" }],
67
- requiresApproval: null,
68
- });
69
-
70
- const result = await runManagedLobsterFlow(createRunFlowParams(taskFlow, runner));
71
-
72
- expect(result.ok).toBe(true);
73
- expect(taskFlow.createManaged).toHaveBeenCalledWith({
74
- controllerId: "tests/lobster",
75
- goal: "Run Lobster workflow",
76
- currentStep: "run_lobster",
77
- });
78
- expect(taskFlow.finish).toHaveBeenCalledWith({
79
- flowId: "flow-1",
80
- expectedRevision: 1,
81
- });
82
- });
83
-
84
- it("moves the flow to waiting when Lobster requests approval", async () => {
85
- const taskFlow = createFakeTaskFlow();
86
- const createdAt = new Date("2026-04-05T21:00:00.000Z");
87
- const runner = createRunner({
88
- ok: true,
89
- status: "needs_approval",
90
- output: [],
91
- requiresApproval: {
92
- type: "approval_request",
93
- prompt: "Approve this?",
94
- items: [{ id: "item-1", createdAt, count: 2n, skip: undefined }],
95
- resumeToken: "resume-1",
96
- },
97
- });
98
-
99
- const result = await runManagedLobsterFlow(createRunFlowParams(taskFlow, runner));
100
-
101
- expect(result.ok).toBe(true);
102
- expect(taskFlow.setWaiting).toHaveBeenCalledWith({
103
- flowId: "flow-1",
104
- expectedRevision: 1,
105
- currentStep: "await_lobster_approval",
106
- waitJson: {
107
- kind: "lobster_approval",
108
- prompt: "Approve this?",
109
- items: [{ id: "item-1", createdAt: createdAt.toISOString(), count: "2" }],
110
- resumeToken: "resume-1",
111
- },
112
- });
113
- });
114
-
115
- it("fails the flow when Lobster returns an error envelope", async () => {
116
- const taskFlow = createFakeTaskFlow();
117
- const runner = createRunner({
118
- ok: false,
119
- error: {
120
- type: "runtime_error",
121
- message: "boom",
122
- },
123
- });
124
-
125
- const result = expectManagedFlowFailure(
126
- await runManagedLobsterFlow(createRunFlowParams(taskFlow, runner)),
127
- );
128
- expect(result.error.message).toBe("boom");
129
- expect(taskFlow.fail).toHaveBeenCalledWith({
130
- flowId: "flow-1",
131
- expectedRevision: 1,
132
- });
133
- });
134
-
135
- it("fails the flow when the runner throws", async () => {
136
- const taskFlow = createFakeTaskFlow();
137
- const runner: LobsterRunner = {
138
- run: vi.fn().mockRejectedValue(new Error("crashed")),
139
- };
140
-
141
- const result = expectManagedFlowFailure(
142
- await runManagedLobsterFlow(createRunFlowParams(taskFlow, runner)),
143
- );
144
- expect(result.error.message).toBe("crashed");
145
- expect(taskFlow.fail).toHaveBeenCalledWith({
146
- flowId: "flow-1",
147
- expectedRevision: 1,
148
- });
149
- });
150
- });
151
-
152
- describe("resumeManagedLobsterFlow", () => {
153
- it("resumes the flow and finishes it on success", async () => {
154
- const taskFlow = createFakeTaskFlow();
155
- const runner = createRunner({
156
- ok: true,
157
- status: "ok",
158
- output: [],
159
- requiresApproval: null,
160
- });
161
-
162
- const result = await resumeManagedLobsterFlow(createResumeFlowParams(taskFlow, runner));
163
-
164
- expect(result.ok).toBe(true);
165
- expect(taskFlow.resume).toHaveBeenCalledWith({
166
- flowId: "flow-1",
167
- expectedRevision: 4,
168
- status: "running",
169
- currentStep: "resume_lobster",
170
- });
171
- expect(taskFlow.finish).toHaveBeenCalledWith({
172
- flowId: "flow-1",
173
- expectedRevision: 5,
174
- });
175
- });
176
-
177
- it("returns a mutation error when taskFlow resume is rejected", async () => {
178
- const taskFlow = createFakeTaskFlow({
179
- resume: vi.fn().mockReturnValue({
180
- applied: false,
181
- code: "revision_conflict",
182
- }),
183
- });
184
- const runner = createRunner({
185
- ok: true,
186
- status: "ok",
187
- output: [],
188
- requiresApproval: null,
189
- });
190
-
191
- const result = expectManagedFlowFailure(
192
- await resumeManagedLobsterFlow(createResumeFlowParams(taskFlow, runner)),
193
- );
194
- expect(result.error.message).toMatch(/revision_conflict/);
195
- expect(runner.run).not.toHaveBeenCalled();
196
- });
197
-
198
- it("returns to waiting when the resumed Lobster run needs approval again", async () => {
199
- const taskFlow = createFakeTaskFlow();
200
- const runner = createRunner({
201
- ok: true,
202
- status: "needs_approval",
203
- output: [],
204
- requiresApproval: {
205
- type: "approval_request",
206
- prompt: "Approve this too?",
207
- items: [{ id: "item-2" }],
208
- resumeToken: "resume-2",
209
- },
210
- });
211
-
212
- const result = await resumeManagedLobsterFlow(createResumeFlowParams(taskFlow, runner));
213
-
214
- expect(result.ok).toBe(true);
215
- expect(taskFlow.setWaiting).toHaveBeenCalledWith({
216
- flowId: "flow-1",
217
- expectedRevision: 5,
218
- currentStep: "await_lobster_approval",
219
- waitJson: {
220
- kind: "lobster_approval",
221
- prompt: "Approve this too?",
222
- items: [{ id: "item-2" }],
223
- resumeToken: "resume-2",
224
- },
225
- });
226
- });
227
- });