diff --git a/extensions/lobster/src/lobster-runner.test.ts b/extensions/lobster/src/lobster-runner.test.ts index c813be2f72e0..b895596c68fd 100644 --- a/extensions/lobster/src/lobster-runner.test.ts +++ b/extensions/lobster/src/lobster-runner.test.ts @@ -134,6 +134,46 @@ describe("createEmbeddedLobsterRunner", () => { }); }); + it.each([ + "exec --json=true cat data.json", + "exec --json=true cat config.yaml", + "exec --json=true cat flow.lobster", + "exec --json=true cat /tmp/missing.json", + "http.fetch https://example.test/workflows/flow.lobster", + "exec --json=true echo nested/path", + ])("keeps inline pipeline with file-like args as a pipeline: %s", async (pipeline) => { + const runtime = { + runToolRequest: vi.fn().mockResolvedValue({ + ok: true, + protocolVersion: 1, + status: "ok", + output: [], + requiresApproval: null, + }), + resumeToolRequest: vi.fn(), + }; + + const runner = createEmbeddedLobsterRunner({ + loadRuntime: vi.fn().mockResolvedValue(runtime), + }); + + await runner.run({ + action: "run", + pipeline, + cwd: process.cwd(), + timeoutMs: 2000, + maxStdoutBytes: 4096, + }); + + expect(runtime.runToolRequest).toHaveBeenCalledOnce(); + const request = requireRecord( + requireFirstCallParam(runtime.runToolRequest.mock.calls, "inline run tool request"), + "inline run tool request", + ); + expect(request.pipeline).toBe(pipeline); + expect(request.filePath).toBeUndefined(); + }); + it("detects workflow files and parses argsJson", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-runner-")); const workflowPath = path.join(tempDir, "workflow.lobster"); @@ -177,6 +217,80 @@ describe("createEmbeddedLobsterRunner", () => { } }); + it("detects existing workflow file paths that contain spaces", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-runner-")); + const workflowPath = path.join(tempDir, "daily inbox.lobster"); + await fs.writeFile(workflowPath, "steps: []\n", "utf8"); + + try { + const runtime = { + runToolRequest: vi.fn().mockResolvedValue({ + ok: true, + protocolVersion: 1, + status: "ok", + output: [], + requiresApproval: null, + }), + resumeToolRequest: vi.fn(), + }; + + const runner = createEmbeddedLobsterRunner({ + loadRuntime: vi.fn().mockResolvedValue(runtime), + }); + + await runner.run({ + action: "run", + pipeline: "daily inbox.lobster", + cwd: tempDir, + timeoutMs: 2000, + maxStdoutBytes: 4096, + }); + + expect(runtime.runToolRequest).toHaveBeenCalledOnce(); + const request = requireRecord( + requireFirstCallParam(runtime.runToolRequest.mock.calls, "workflow file with spaces"), + "workflow file with spaces", + ); + expect(request.filePath).toBe(workflowPath); + expect(request.pipeline).toBeUndefined(); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it.each([ + ["missing.lobster", "missing.lobster"], + ["nested/missing.yaml", path.join("nested", "missing.yaml")], + ])("surfaces missing workflow path errors for %s", async (pipeline, expectedRelativePath) => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-runner-")); + + try { + const runtime = { + runToolRequest: vi.fn(), + resumeToolRequest: vi.fn(), + }; + const runner = createEmbeddedLobsterRunner({ + loadRuntime: vi.fn().mockResolvedValue(runtime), + }); + + await expect( + runner.run({ + action: "run", + pipeline, + cwd: tempDir, + timeoutMs: 2000, + maxStdoutBytes: 4096, + }), + ).rejects.toMatchObject({ + code: "ENOENT", + path: path.join(tempDir, expectedRelativePath), + }); + expect(runtime.runToolRequest).not.toHaveBeenCalled(); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + it("returns a parse error when workflow args are invalid JSON", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-runner-")); const workflowPath = path.join(tempDir, "workflow.lobster"); diff --git a/extensions/lobster/src/lobster-runner.ts b/extensions/lobster/src/lobster-runner.ts index 3f7181fc09a3..b8b6e97ca39f 100644 --- a/extensions/lobster/src/lobster-runner.ts +++ b/extensions/lobster/src/lobster-runner.ts @@ -104,6 +104,7 @@ type LoadEmbeddedToolRuntimeFromPackageOptions = { }; const lobsterRequire = createRequire(import.meta.url); +const workflowExts = new Set([".lobster", ".yaml", ".yml", ".json"]); function toEmbeddedToolRuntime( moduleExports: Partial, @@ -230,21 +231,40 @@ async function resolveWorkflowFile(candidate: string, cwd: string) { throw new Error("Workflow path is not a file"); } const ext = path.extname(resolved).toLowerCase(); - if (![".lobster", ".yaml", ".yml", ".json"].includes(ext)) { + if (!workflowExts.has(ext)) { throw new Error("Workflow file must end in .lobster, .yaml, .yml, or .json"); } return resolved; } +function isMissingPathError(error: unknown) { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: unknown }).code === "ENOENT" + ); +} + +function hasWorkflowFileExtension(candidate: string) { + return workflowExts.has(path.extname(candidate).toLowerCase()); +} + async function detectWorkflowFile(candidate: string, cwd: string) { const trimmed = candidate.trim(); - if (!trimmed || trimmed.includes("|")) { + if (!trimmed || trimmed.includes("|") || !hasWorkflowFileExtension(trimmed)) { return null; } + if (!/\s/.test(trimmed)) { + return await resolveWorkflowFile(trimmed, cwd); + } try { return await resolveWorkflowFile(trimmed, cwd); - } catch { - return null; + } catch (error) { + if (isMissingPathError(error)) { + return null; + } + throw error; } }