mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-08 02:52:15 +00:00
fix(lobster): surface workflow path errors
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>
This commit is contained in:
committed by
GitHub
parent
808f677ab4
commit
c5db07eddc
@@ -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");
|
||||
|
||||
@@ -104,6 +104,7 @@ type LoadEmbeddedToolRuntimeFromPackageOptions = {
|
||||
};
|
||||
|
||||
const lobsterRequire = createRequire(import.meta.url);
|
||||
const workflowExts = new Set([".lobster", ".yaml", ".yml", ".json"]);
|
||||
|
||||
function toEmbeddedToolRuntime(
|
||||
moduleExports: Partial<EmbeddedToolRuntime>,
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user