From c5db07eddcde388604b85c4563e42313b789e0bc Mon Sep 17 00:00:00 2001 From: "openclaw-clownfish[bot]" <280122609+openclaw-clownfish[bot]@users.noreply.github.com> Date: Sun, 14 Jun 2026 12:18:33 +0800 Subject: [PATCH] fix(lobster): surface workflow path errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface missing bare Lobster workflow file paths instead of silently falling through to inline pipeline parsing. The runner now treats plain workflow file inputs as file paths, keeps inline commands with file-like arguments as pipelines, and preserves existing workflow file paths that contain spaces. Regression coverage covers missing bare workflow paths, inline false positives, and spaced workflow filenames. Fixes #68101. Based on and credits #68106 by @vvitovec. This replacement branch carries the focused fix forward because #68106 is dirty against current main and could not be repaired on the fork branch with available bot permissions. Validation: - node scripts/run-vitest.mjs extensions/lobster/src/lobster-runner.test.ts - autoreview clean: no accepted/actionable findings after the spaced-path fix - GitHub checks: 127 pass, 0 fail, 0 pending Co-authored-by: Viktor VĂ­tovec <230458341+vvitovec@users.noreply.github.com> --- extensions/lobster/src/lobster-runner.test.ts | 114 ++++++++++++++++++ extensions/lobster/src/lobster-runner.ts | 28 ++++- 2 files changed, 138 insertions(+), 4 deletions(-) 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; } }