mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
refactor(lobster): remove lobsterPath overrides
This commit is contained in:
@@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/Refactor: centralize hardened temp-file path generation for Feishu and LINE media downloads via shared `buildRandomTempFilePath` helper to reduce drift risk. (#20810) Thanks @mbelinky.
|
||||
- Security/Media: harden local media ingestion against TOCTOU/symlink swap attacks by pinning reads to a single file descriptor with symlink rejection and inode/device verification in `saveMediaSource`. Thanks @dorjoos for reporting.
|
||||
- Security/Lobster (Windows): for the next npm release, remove shell-based fallback when launching Lobster wrappers (`.cmd`/`.bat`) and switch to explicit argv execution with wrapper entrypoint resolution, preventing command injection while preserving Windows wrapper compatibility. Thanks @allsmog for reporting.
|
||||
- Lobster/Config: remove Lobster executable-path overrides (`lobsterPath`), require PATH-based execution, and add focused Windows wrapper-resolution tests to keep shell-free behavior stable.
|
||||
- Agents/Streaming: keep assistant partial streaming active during reasoning streams, handle native `thinking_*` stream events consistently, dedupe mixed reasoning-end signals, and clear stale mutating tool errors after same-target retry success. (#20635) Thanks @obviyus.
|
||||
- Security/OTEL: sanitize OTLP endpoint URL resolution. (#13791) Thanks @vincentkoc.
|
||||
- OTEL/diagnostics-otel: complete OpenTelemetry v2 API migration. (#12897) Thanks @vincentkoc.
|
||||
|
||||
@@ -211,7 +211,7 @@ For ad-hoc workflows, call Lobster directly.
|
||||
- Lobster runs as a **local subprocess** (`lobster` CLI) in tool mode and returns a **JSON envelope**.
|
||||
- If the tool returns `needs_approval`, you resume with a `resumeToken` and `approve` flag.
|
||||
- The tool is an **optional plugin**; enable it additively via `tools.alsoAllow: ["lobster"]` (recommended).
|
||||
- If you pass `lobsterPath`, it must be an **absolute path**.
|
||||
- Lobster expects the `lobster` CLI to be available on `PATH`.
|
||||
|
||||
See [Lobster](/tools/lobster) for full usage and examples.
|
||||
|
||||
|
||||
@@ -154,7 +154,6 @@ Notes:
|
||||
## Install Lobster
|
||||
|
||||
Install the Lobster CLI on the **same host** that runs the OpenClaw Gateway (see the [Lobster repo](https://github.com/openclaw/lobster)), and ensure `lobster` is on `PATH`.
|
||||
If you want to use a custom binary location, pass an **absolute** `lobsterPath` in the tool call.
|
||||
|
||||
## Enable the tool
|
||||
|
||||
@@ -256,7 +255,7 @@ Run a pipeline in tool mode.
|
||||
{
|
||||
"action": "run",
|
||||
"pipeline": "gog.gmail.search --query 'newer_than:1d' | email.triage",
|
||||
"cwd": "/path/to/workspace",
|
||||
"cwd": "workspace",
|
||||
"timeoutMs": 30000,
|
||||
"maxStdoutBytes": 512000
|
||||
}
|
||||
@@ -286,8 +285,7 @@ Continue a halted workflow after approval.
|
||||
|
||||
### Optional inputs
|
||||
|
||||
- `lobsterPath`: Absolute path to the Lobster binary (omit to use `PATH`).
|
||||
- `cwd`: Working directory for the pipeline (defaults to the current process working directory).
|
||||
- `cwd`: Relative working directory for the pipeline (must stay within the current process working directory).
|
||||
- `timeoutMs`: Kill the subprocess if it exceeds this duration (default: 20000).
|
||||
- `maxStdoutBytes`: Kill the subprocess if stdout exceeds this size (default: 512000).
|
||||
- `argsJson`: JSON string passed to `lobster run --args-json` (workflow files only).
|
||||
@@ -320,7 +318,7 @@ OpenProse pairs well with Lobster: use `/prose` to orchestrate multi-agent prep,
|
||||
- **Local subprocess only** — no network calls from the plugin itself.
|
||||
- **No secrets** — Lobster doesn't manage OAuth; it calls OpenClaw tools that do.
|
||||
- **Sandbox-aware** — disabled when the tool context is sandboxed.
|
||||
- **Hardened** — `lobsterPath` must be absolute if specified; timeouts and output caps enforced.
|
||||
- **Hardened** — fixed executable name (`lobster`) on `PATH`; timeouts and output caps enforced.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -72,4 +72,4 @@ Notes:
|
||||
- Runs the `lobster` executable as a local subprocess.
|
||||
- Does not manage OAuth/tokens.
|
||||
- Uses timeouts, stdout caps, and strict JSON envelope parsing.
|
||||
- Prefer an absolute `lobsterPath` in production to avoid PATH hijack.
|
||||
- Ensure `lobster` is available on `PATH` for the gateway process.
|
||||
|
||||
@@ -66,8 +66,6 @@ function setProcessPlatform(platform: NodeJS.Platform) {
|
||||
|
||||
describe("lobster plugin tool", () => {
|
||||
let tempDir = "";
|
||||
let lobsterBinPath = "";
|
||||
let lobsterExePath = "";
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
const originalPath = process.env.PATH;
|
||||
const originalPathAlt = process.env.Path;
|
||||
@@ -78,10 +76,6 @@ describe("lobster plugin tool", () => {
|
||||
({ createLobsterTool } = await import("./lobster-tool.js"));
|
||||
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-plugin-"));
|
||||
lobsterBinPath = path.join(tempDir, process.platform === "win32" ? "lobster.cmd" : "lobster");
|
||||
lobsterExePath = path.join(tempDir, "lobster.exe");
|
||||
await fs.writeFile(lobsterBinPath, "", { encoding: "utf8", mode: 0o755 });
|
||||
await fs.writeFile(lobsterExePath, "", { encoding: "utf8", mode: 0o755 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -151,6 +145,28 @@ describe("lobster plugin tool", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const queueSuccessfulEnvelope = (hello = "world") => {
|
||||
spawnState.queue.push({
|
||||
stdout: JSON.stringify({
|
||||
ok: true,
|
||||
status: "ok",
|
||||
output: [{ hello }],
|
||||
requiresApproval: null,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const createWindowsShimFixture = async (params: {
|
||||
shimPath: string;
|
||||
scriptPath: string;
|
||||
scriptToken: string;
|
||||
}) => {
|
||||
await fs.mkdir(path.dirname(params.scriptPath), { recursive: true });
|
||||
await fs.mkdir(path.dirname(params.shimPath), { recursive: true });
|
||||
await fs.writeFile(params.scriptPath, "module.exports = {};\n", "utf8");
|
||||
await fs.writeFile(params.shimPath, `@echo off\r\n"${params.scriptToken}" %*\r\n`, "utf8");
|
||||
};
|
||||
|
||||
it("runs lobster and returns parsed envelope in details", async () => {
|
||||
spawnState.queue.push({
|
||||
stdout: JSON.stringify({
|
||||
@@ -188,26 +204,43 @@ describe("lobster plugin tool", () => {
|
||||
expect(res.details).toMatchObject({ ok: true, status: "ok" });
|
||||
});
|
||||
|
||||
it("requires absolute lobsterPath when provided (even though it is ignored)", async () => {
|
||||
it("requires action", async () => {
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
await expect(
|
||||
tool.execute("call2", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
lobsterPath: "./lobster",
|
||||
}),
|
||||
).rejects.toThrow(/absolute path/);
|
||||
await expect(tool.execute("call-action-missing", {})).rejects.toThrow(/action required/);
|
||||
});
|
||||
|
||||
it("rejects lobsterPath (deprecated) when invalid", async () => {
|
||||
it("requires pipeline for run action", async () => {
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
await expect(
|
||||
tool.execute("call2b", {
|
||||
tool.execute("call-pipeline-missing", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
lobsterPath: "/bin/bash",
|
||||
}),
|
||||
).rejects.toThrow(/lobster executable/);
|
||||
).rejects.toThrow(/pipeline required/);
|
||||
});
|
||||
|
||||
it("requires token and approve for resume action", async () => {
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
await expect(
|
||||
tool.execute("call-resume-token-missing", {
|
||||
action: "resume",
|
||||
approve: true,
|
||||
}),
|
||||
).rejects.toThrow(/token required/);
|
||||
await expect(
|
||||
tool.execute("call-resume-approve-missing", {
|
||||
action: "resume",
|
||||
token: "resume-token",
|
||||
}),
|
||||
).rejects.toThrow(/approve required/);
|
||||
});
|
||||
|
||||
it("rejects unknown action", async () => {
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
await expect(
|
||||
tool.execute("call-action-unknown", {
|
||||
action: "explode",
|
||||
}),
|
||||
).rejects.toThrow(/Unknown action/);
|
||||
});
|
||||
|
||||
it("rejects absolute cwd", async () => {
|
||||
@@ -232,32 +265,6 @@ describe("lobster plugin tool", () => {
|
||||
).rejects.toThrow(/must stay within/);
|
||||
});
|
||||
|
||||
it("uses pluginConfig.lobsterPath when provided", async () => {
|
||||
spawnState.queue.push({
|
||||
stdout: JSON.stringify({
|
||||
ok: true,
|
||||
status: "ok",
|
||||
output: [{ hello: "world" }],
|
||||
requiresApproval: null,
|
||||
}),
|
||||
});
|
||||
|
||||
const configuredLobsterPath = process.platform === "win32" ? lobsterExePath : lobsterBinPath;
|
||||
const tool = createLobsterTool(
|
||||
fakeApi({ pluginConfig: { lobsterPath: configuredLobsterPath } }),
|
||||
);
|
||||
const res = await tool.execute("call-plugin-config", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
expect(spawnState.spawn).toHaveBeenCalled();
|
||||
const [execPath] = spawnState.spawn.mock.calls[0] ?? [];
|
||||
expect(execPath).toBe(configuredLobsterPath);
|
||||
expect(res.details).toMatchObject({ ok: true, status: "ok" });
|
||||
});
|
||||
|
||||
it("rejects invalid JSON from lobster", async () => {
|
||||
spawnState.queue.push({ stdout: "nope" });
|
||||
|
||||
@@ -273,25 +280,17 @@ describe("lobster plugin tool", () => {
|
||||
it("runs Windows cmd shims through Node without enabling shell", async () => {
|
||||
setProcessPlatform("win32");
|
||||
const shimScriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs");
|
||||
const shimPath = path.join(tempDir, "shim", "lobster.cmd");
|
||||
await fs.mkdir(path.dirname(shimScriptPath), { recursive: true });
|
||||
await fs.mkdir(path.dirname(shimPath), { recursive: true });
|
||||
await fs.writeFile(shimScriptPath, "module.exports = {};\n", "utf8");
|
||||
await fs.writeFile(
|
||||
const shimPath = path.join(tempDir, "shim-bin", "lobster.cmd");
|
||||
await createWindowsShimFixture({
|
||||
shimPath,
|
||||
`@echo off\r\n"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*\r\n`,
|
||||
"utf8",
|
||||
);
|
||||
spawnState.queue.push({
|
||||
stdout: JSON.stringify({
|
||||
ok: true,
|
||||
status: "ok",
|
||||
output: [{ hello: "world" }],
|
||||
requiresApproval: null,
|
||||
}),
|
||||
scriptPath: shimScriptPath,
|
||||
scriptToken: "%dp0%\\..\\shim-dist\\lobster-cli.cjs",
|
||||
});
|
||||
process.env.PATHEXT = ".CMD;.EXE";
|
||||
process.env.PATH = `${path.dirname(shimPath)};${process.env.PATH ?? ""}`;
|
||||
queueSuccessfulEnvelope();
|
||||
|
||||
const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: shimPath } }));
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
await tool.execute("call-win-shim", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
@@ -304,127 +303,6 @@ describe("lobster plugin tool", () => {
|
||||
expect(options).not.toHaveProperty("shell");
|
||||
});
|
||||
|
||||
it("runs Windows cmd shims with rooted dp0 tokens through Node", async () => {
|
||||
setProcessPlatform("win32");
|
||||
const shimScriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs");
|
||||
const shimPath = path.join(tempDir, "shim", "lobster.cmd");
|
||||
await fs.mkdir(path.dirname(shimScriptPath), { recursive: true });
|
||||
await fs.mkdir(path.dirname(shimPath), { recursive: true });
|
||||
await fs.writeFile(shimScriptPath, "module.exports = {};\n", "utf8");
|
||||
await fs.writeFile(
|
||||
shimPath,
|
||||
`@echo off\r\n"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*\r\n`,
|
||||
"utf8",
|
||||
);
|
||||
spawnState.queue.push({
|
||||
stdout: JSON.stringify({
|
||||
ok: true,
|
||||
status: "ok",
|
||||
output: [{ hello: "rooted" }],
|
||||
requiresApproval: null,
|
||||
}),
|
||||
});
|
||||
|
||||
const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: shimPath } }));
|
||||
await tool.execute("call-win-rooted-shim", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
});
|
||||
|
||||
const [command, argv] = spawnState.spawn.mock.calls[0] ?? [];
|
||||
expect(command).toBe(process.execPath);
|
||||
expect(argv).toEqual([shimScriptPath, "run", "--mode", "tool", "noop"]);
|
||||
});
|
||||
|
||||
it("ignores node.exe shim entries and resolves the actual lobster script", async () => {
|
||||
setProcessPlatform("win32");
|
||||
const shimDir = path.join(tempDir, "shim-with-node");
|
||||
const nodeExePath = path.join(shimDir, "node.exe");
|
||||
const scriptPath = path.join(tempDir, "shim-dist-node", "lobster-cli.cjs");
|
||||
const shimPath = path.join(shimDir, "lobster.cmd");
|
||||
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
|
||||
await fs.mkdir(shimDir, { recursive: true });
|
||||
await fs.writeFile(nodeExePath, "", "utf8");
|
||||
await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8");
|
||||
await fs.writeFile(
|
||||
shimPath,
|
||||
`@echo off\r\n"%~dp0%\\node.exe" "%~dp0%\\..\\shim-dist-node\\lobster-cli.cjs" %*\r\n`,
|
||||
"utf8",
|
||||
);
|
||||
spawnState.queue.push({
|
||||
stdout: JSON.stringify({
|
||||
ok: true,
|
||||
status: "ok",
|
||||
output: [{ hello: "node-first" }],
|
||||
requiresApproval: null,
|
||||
}),
|
||||
});
|
||||
|
||||
const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: shimPath } }));
|
||||
await tool.execute("call-win-node-first", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
});
|
||||
|
||||
const [command, argv] = spawnState.spawn.mock.calls[0] ?? [];
|
||||
expect(command).toBe(process.execPath);
|
||||
expect(argv).toEqual([scriptPath, "run", "--mode", "tool", "noop"]);
|
||||
});
|
||||
|
||||
it("resolves lobster.cmd from PATH and unwraps npm layout shim", async () => {
|
||||
setProcessPlatform("win32");
|
||||
const binDir = path.join(tempDir, "node_modules", ".bin");
|
||||
const packageDir = path.join(tempDir, "node_modules", "lobster");
|
||||
const scriptPath = path.join(packageDir, "dist", "cli.js");
|
||||
const shimPath = path.join(binDir, "lobster.cmd");
|
||||
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
await fs.writeFile(shimPath, "@echo off\r\n", "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(packageDir, "package.json"),
|
||||
JSON.stringify({ name: "lobster", version: "0.0.0", bin: { lobster: "dist/cli.js" } }),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8");
|
||||
process.env.PATHEXT = ".CMD;.EXE";
|
||||
process.env.PATH = `${binDir};${process.env.PATH ?? ""}`;
|
||||
|
||||
spawnState.queue.push({
|
||||
stdout: JSON.stringify({
|
||||
ok: true,
|
||||
status: "ok",
|
||||
output: [{ hello: "path" }],
|
||||
requiresApproval: null,
|
||||
}),
|
||||
});
|
||||
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
await tool.execute("call-win-path", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
});
|
||||
|
||||
const [command, argv] = spawnState.spawn.mock.calls[0] ?? [];
|
||||
expect(command).toBe(process.execPath);
|
||||
expect(argv).toEqual([scriptPath, "run", "--mode", "tool", "noop"]);
|
||||
});
|
||||
|
||||
it("fails fast when cmd wrapper cannot be resolved without shell execution", async () => {
|
||||
setProcessPlatform("win32");
|
||||
const badShimPath = path.join(tempDir, "bad-shim", "lobster.cmd");
|
||||
await fs.mkdir(path.dirname(badShimPath), { recursive: true });
|
||||
await fs.writeFile(badShimPath, "@echo off\r\nREM no entrypoint\r\n", "utf8");
|
||||
|
||||
const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: badShimPath } }));
|
||||
await expect(
|
||||
tool.execute("call-win-bad", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
}),
|
||||
).rejects.toThrow(/without shell execution/);
|
||||
expect(spawnState.spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not retry a failed Windows spawn with shell fallback", async () => {
|
||||
setProcessPlatform("win32");
|
||||
spawnState.spawn.mockReset();
|
||||
@@ -442,7 +320,7 @@ describe("lobster plugin tool", () => {
|
||||
return child;
|
||||
});
|
||||
|
||||
const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: lobsterExePath } }));
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
await expect(
|
||||
tool.execute("call-win-no-retry", {
|
||||
action: "run",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { OpenClawPluginApi } from "../../../src/plugins/types.js";
|
||||
@@ -22,43 +21,6 @@ type LobsterEnvelope =
|
||||
error: { type?: string; message: string };
|
||||
};
|
||||
|
||||
function resolveExecutablePath(lobsterPathRaw: string | undefined) {
|
||||
const lobsterPath = lobsterPathRaw?.trim() || "lobster";
|
||||
|
||||
// SECURITY:
|
||||
// Never allow arbitrary executables (e.g. /bin/bash). If the caller overrides
|
||||
// the path, it must still be the lobster binary (by name) and be absolute.
|
||||
if (lobsterPath !== "lobster") {
|
||||
if (!path.isAbsolute(lobsterPath)) {
|
||||
throw new Error("lobsterPath must be an absolute path (or omit to use PATH)");
|
||||
}
|
||||
const base = path.basename(lobsterPath).toLowerCase();
|
||||
const allowed =
|
||||
process.platform === "win32" ? ["lobster.exe", "lobster.cmd", "lobster.bat"] : ["lobster"];
|
||||
if (!allowed.includes(base)) {
|
||||
throw new Error("lobsterPath must point to the lobster executable");
|
||||
}
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(lobsterPath);
|
||||
} catch {
|
||||
throw new Error("lobsterPath must exist");
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
throw new Error("lobsterPath must point to a file");
|
||||
}
|
||||
if (process.platform !== "win32") {
|
||||
try {
|
||||
fs.accessSync(lobsterPath, fs.constants.X_OK);
|
||||
} catch {
|
||||
throw new Error("lobsterPath must be executable");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lobsterPath;
|
||||
}
|
||||
|
||||
function normalizeForCwdSandbox(p: string): string {
|
||||
const normalized = path.normalize(p);
|
||||
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
||||
@@ -180,16 +142,6 @@ async function runLobsterSubprocessOnce(params: {
|
||||
});
|
||||
}
|
||||
|
||||
async function runLobsterSubprocess(params: {
|
||||
execPath: string;
|
||||
argv: string[];
|
||||
cwd: string;
|
||||
timeoutMs: number;
|
||||
maxStdoutBytes: number;
|
||||
}) {
|
||||
return await runLobsterSubprocessOnce(params);
|
||||
}
|
||||
|
||||
function parseEnvelope(stdout: string): LobsterEnvelope {
|
||||
const trimmed = stdout.trim();
|
||||
|
||||
@@ -228,6 +180,33 @@ function parseEnvelope(stdout: string): LobsterEnvelope {
|
||||
throw new Error("lobster returned invalid JSON envelope");
|
||||
}
|
||||
|
||||
function buildLobsterArgv(action: string, params: Record<string, unknown>): string[] {
|
||||
if (action === "run") {
|
||||
const pipeline = typeof params.pipeline === "string" ? params.pipeline : "";
|
||||
if (!pipeline.trim()) {
|
||||
throw new Error("pipeline required");
|
||||
}
|
||||
const argv = ["run", "--mode", "tool", pipeline];
|
||||
const argsJson = typeof params.argsJson === "string" ? params.argsJson : "";
|
||||
if (argsJson.trim()) {
|
||||
argv.push("--args-json", argsJson);
|
||||
}
|
||||
return argv;
|
||||
}
|
||||
if (action === "resume") {
|
||||
const token = typeof params.token === "string" ? params.token : "";
|
||||
if (!token.trim()) {
|
||||
throw new Error("token required");
|
||||
}
|
||||
const approve = params.approve;
|
||||
if (typeof approve !== "boolean") {
|
||||
throw new Error("approve required");
|
||||
}
|
||||
return ["resume", "--token", token, "--approve", approve ? "yes" : "no"];
|
||||
}
|
||||
throw new Error(`Unknown action: ${action}`);
|
||||
}
|
||||
|
||||
export function createLobsterTool(api: OpenClawPluginApi) {
|
||||
return {
|
||||
name: "lobster",
|
||||
@@ -241,11 +220,6 @@ export function createLobsterTool(api: OpenClawPluginApi) {
|
||||
argsJson: Type.Optional(Type.String()),
|
||||
token: Type.Optional(Type.String()),
|
||||
approve: Type.Optional(Type.Boolean()),
|
||||
// SECURITY: Do not allow the agent to choose an executable path.
|
||||
// Host can configure the lobster binary via plugin config.
|
||||
lobsterPath: Type.Optional(
|
||||
Type.String({ description: "(deprecated) Use plugin config instead." }),
|
||||
),
|
||||
cwd: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
@@ -261,55 +235,19 @@ export function createLobsterTool(api: OpenClawPluginApi) {
|
||||
throw new Error("action required");
|
||||
}
|
||||
|
||||
// SECURITY: never allow tool callers (agent/user) to select executables.
|
||||
// If a host needs to override the binary, it must do so via plugin config.
|
||||
// We still validate the parameter shape to prevent reintroducing an RCE footgun.
|
||||
if (typeof params.lobsterPath === "string" && params.lobsterPath.trim()) {
|
||||
resolveExecutablePath(params.lobsterPath);
|
||||
}
|
||||
|
||||
const execPath = resolveExecutablePath(
|
||||
typeof api.pluginConfig?.lobsterPath === "string"
|
||||
? api.pluginConfig.lobsterPath
|
||||
: undefined,
|
||||
);
|
||||
const execPath = "lobster";
|
||||
const cwd = resolveCwd(params.cwd);
|
||||
const timeoutMs = typeof params.timeoutMs === "number" ? params.timeoutMs : 20_000;
|
||||
const maxStdoutBytes =
|
||||
typeof params.maxStdoutBytes === "number" ? params.maxStdoutBytes : 512_000;
|
||||
|
||||
const argv = (() => {
|
||||
if (action === "run") {
|
||||
const pipeline = typeof params.pipeline === "string" ? params.pipeline : "";
|
||||
if (!pipeline.trim()) {
|
||||
throw new Error("pipeline required");
|
||||
}
|
||||
const argv = ["run", "--mode", "tool", pipeline];
|
||||
const argsJson = typeof params.argsJson === "string" ? params.argsJson : "";
|
||||
if (argsJson.trim()) {
|
||||
argv.push("--args-json", argsJson);
|
||||
}
|
||||
return argv;
|
||||
}
|
||||
if (action === "resume") {
|
||||
const token = typeof params.token === "string" ? params.token : "";
|
||||
if (!token.trim()) {
|
||||
throw new Error("token required");
|
||||
}
|
||||
const approve = params.approve;
|
||||
if (typeof approve !== "boolean") {
|
||||
throw new Error("approve required");
|
||||
}
|
||||
return ["resume", "--token", token, "--approve", approve ? "yes" : "no"];
|
||||
}
|
||||
throw new Error(`Unknown action: ${action}`);
|
||||
})();
|
||||
const argv = buildLobsterArgv(action, params);
|
||||
|
||||
if (api.runtime?.version && api.logger?.debug) {
|
||||
api.logger.debug(`lobster plugin runtime=${api.runtime.version}`);
|
||||
}
|
||||
|
||||
const { stdout } = await runLobsterSubprocess({
|
||||
const { stdout } = await runLobsterSubprocessOnce({
|
||||
execPath,
|
||||
argv,
|
||||
cwd,
|
||||
|
||||
148
extensions/lobster/src/windows-spawn.test.ts
Normal file
148
extensions/lobster/src/windows-spawn.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { resolveWindowsLobsterSpawn } from "./windows-spawn.js";
|
||||
|
||||
function setProcessPlatform(platform: NodeJS.Platform) {
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: platform,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
describe("resolveWindowsLobsterSpawn", () => {
|
||||
let tempDir = "";
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
const originalPath = process.env.PATH;
|
||||
const originalPathAlt = process.env.Path;
|
||||
const originalPathExt = process.env.PATHEXT;
|
||||
const originalPathExtAlt = process.env.Pathext;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-win-spawn-"));
|
||||
setProcessPlatform("win32");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, "platform", originalPlatform);
|
||||
}
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.PATH;
|
||||
} else {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
if (originalPathAlt === undefined) {
|
||||
delete process.env.Path;
|
||||
} else {
|
||||
process.env.Path = originalPathAlt;
|
||||
}
|
||||
if (originalPathExt === undefined) {
|
||||
delete process.env.PATHEXT;
|
||||
} else {
|
||||
process.env.PATHEXT = originalPathExt;
|
||||
}
|
||||
if (originalPathExtAlt === undefined) {
|
||||
delete process.env.Pathext;
|
||||
} else {
|
||||
process.env.Pathext = originalPathExtAlt;
|
||||
}
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
tempDir = "";
|
||||
}
|
||||
});
|
||||
|
||||
it("unwraps cmd shim with %dp0% token", async () => {
|
||||
const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs");
|
||||
const shimPath = path.join(tempDir, "shim", "lobster.cmd");
|
||||
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
|
||||
await fs.mkdir(path.dirname(shimPath), { recursive: true });
|
||||
await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8");
|
||||
await fs.writeFile(
|
||||
shimPath,
|
||||
`@echo off\r\n"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*\r\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env);
|
||||
expect(target.command).toBe(process.execPath);
|
||||
expect(target.argv).toEqual([scriptPath, "run", "noop"]);
|
||||
expect(target.windowsHide).toBe(true);
|
||||
});
|
||||
|
||||
it("unwraps cmd shim with %~dp0% token", async () => {
|
||||
const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs");
|
||||
const shimPath = path.join(tempDir, "shim", "lobster.cmd");
|
||||
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
|
||||
await fs.mkdir(path.dirname(shimPath), { recursive: true });
|
||||
await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8");
|
||||
await fs.writeFile(
|
||||
shimPath,
|
||||
`@echo off\r\n"%~dp0%\\..\\shim-dist\\lobster-cli.cjs" %*\r\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env);
|
||||
expect(target.command).toBe(process.execPath);
|
||||
expect(target.argv).toEqual([scriptPath, "run", "noop"]);
|
||||
expect(target.windowsHide).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores node.exe shim entries and picks lobster script", async () => {
|
||||
const shimDir = path.join(tempDir, "shim-with-node");
|
||||
const scriptPath = path.join(tempDir, "shim-dist-node", "lobster-cli.cjs");
|
||||
const shimPath = path.join(shimDir, "lobster.cmd");
|
||||
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
|
||||
await fs.mkdir(shimDir, { recursive: true });
|
||||
await fs.writeFile(path.join(shimDir, "node.exe"), "", "utf8");
|
||||
await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8");
|
||||
await fs.writeFile(
|
||||
shimPath,
|
||||
`@echo off\r\n"%~dp0%\\node.exe" "%~dp0%\\..\\shim-dist-node\\lobster-cli.cjs" %*\r\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env);
|
||||
expect(target.command).toBe(process.execPath);
|
||||
expect(target.argv).toEqual([scriptPath, "run", "noop"]);
|
||||
expect(target.windowsHide).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves lobster.cmd from PATH and unwraps npm layout shim", async () => {
|
||||
const binDir = path.join(tempDir, "node_modules", ".bin");
|
||||
const packageDir = path.join(tempDir, "node_modules", "lobster");
|
||||
const scriptPath = path.join(packageDir, "dist", "cli.js");
|
||||
const shimPath = path.join(binDir, "lobster.cmd");
|
||||
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
await fs.writeFile(shimPath, "@echo off\r\n", "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(packageDir, "package.json"),
|
||||
JSON.stringify({ name: "lobster", version: "0.0.0", bin: { lobster: "dist/cli.js" } }),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8");
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
PATH: `${binDir};${process.env.PATH ?? ""}`,
|
||||
PATHEXT: ".CMD;.EXE",
|
||||
};
|
||||
const target = resolveWindowsLobsterSpawn("lobster", ["run", "noop"], env);
|
||||
expect(target.command).toBe(process.execPath);
|
||||
expect(target.argv).toEqual([scriptPath, "run", "noop"]);
|
||||
expect(target.windowsHide).toBe(true);
|
||||
});
|
||||
|
||||
it("fails fast when wrapper cannot be resolved without shell execution", async () => {
|
||||
const badShimPath = path.join(tempDir, "bad-shim", "lobster.cmd");
|
||||
await fs.mkdir(path.dirname(badShimPath), { recursive: true });
|
||||
await fs.writeFile(badShimPath, "@echo off\r\nREM no entrypoint\r\n", "utf8");
|
||||
|
||||
expect(() => resolveWindowsLobsterSpawn(badShimPath, ["run", "noop"], process.env)).toThrow(
|
||||
/without shell execution/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -181,7 +181,7 @@ export function resolveWindowsLobsterSpawn(
|
||||
resolveLobsterScriptFromPackageJson(resolvedExecPath);
|
||||
if (!scriptPath) {
|
||||
throw new Error(
|
||||
`lobsterPath resolved to ${path.basename(resolvedExecPath)} wrapper, but no Node entrypoint could be resolved without shell execution. Configure pluginConfig.lobsterPath to lobster.exe.`,
|
||||
`${path.basename(resolvedExecPath)} wrapper resolved, but no Node entrypoint could be resolved without shell execution. Ensure Lobster is installed and runnable on PATH (prefer lobster.exe).`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user