Compare commits
6 Commits
dev/ci
...
temp/pr-53
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
049e813831 | ||
|
|
b5d51ed07c | ||
|
|
bd087fafcb | ||
|
|
31577f5378 | ||
|
|
0faf4c22e9 | ||
|
|
a2bb2dc368 |
@@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Fixes
|
||||
|
||||
- Telegram: restore draft streaming partials. (#5543) Thanks @obviyus.
|
||||
- fix(lobster): block arbitrary exec via lobsterPath/cwd injection (GHSA-4mhr-g7xj-cg8j). (#5335) Thanks @vignesh07.
|
||||
|
||||
## 2026.1.30
|
||||
|
||||
|
||||
@@ -33,12 +33,13 @@ async function writeFakeLobster(params: { payload: unknown }) {
|
||||
return await writeFakeLobsterScript(scriptBody);
|
||||
}
|
||||
|
||||
function fakeApi(): OpenClawPluginApi {
|
||||
function fakeApi(overrides: Partial<OpenClawPluginApi> = {}): OpenClawPluginApi {
|
||||
return {
|
||||
id: "lobster",
|
||||
name: "lobster",
|
||||
source: "test",
|
||||
config: {} as any,
|
||||
pluginConfig: {},
|
||||
runtime: { version: "test" } as any,
|
||||
logger: { info() {}, warn() {}, error() {}, debug() {} },
|
||||
registerTool() {},
|
||||
@@ -48,7 +49,12 @@ function fakeApi(): OpenClawPluginApi {
|
||||
registerCli() {},
|
||||
registerService() {},
|
||||
registerProvider() {},
|
||||
registerHook() {},
|
||||
registerHttpRoute() {},
|
||||
registerCommand() {},
|
||||
on() {},
|
||||
resolvePath: (p) => p,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,62 +78,579 @@ describe("lobster plugin tool", () => {
|
||||
payload: { ok: true, status: "ok", output: [{ hello: "world" }], requiresApproval: null },
|
||||
});
|
||||
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
const res = await tool.execute("call1", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
lobsterPath: fake.binPath,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = `${fake.dir}${path.delimiter}${originalPath ?? ""}`;
|
||||
|
||||
expect(res.details).toMatchObject({ ok: true, status: "ok" });
|
||||
try {
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
const res = await tool.execute("call1", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
expect(res.details).toMatchObject({ ok: true, status: "ok" });
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
it("tolerates noisy stdout before the JSON envelope", async () => {
|
||||
const payload = { ok: true, status: "ok", output: [], requiresApproval: null };
|
||||
const { binPath } = await writeFakeLobsterScript(
|
||||
const { dir } = await writeFakeLobsterScript(
|
||||
`const payload = ${JSON.stringify(payload)};\n` +
|
||||
`console.log("noise before json");\n` +
|
||||
`process.stdout.write(JSON.stringify(payload));\n`,
|
||||
"openclaw-lobster-plugin-noisy-",
|
||||
);
|
||||
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
const res = await tool.execute("call-noisy", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
lobsterPath: binPath,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ""}`;
|
||||
|
||||
expect(res.details).toMatchObject({ ok: true, status: "ok" });
|
||||
});
|
||||
|
||||
it("requires absolute lobsterPath when provided", async () => {
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
await expect(
|
||||
tool.execute("call2", {
|
||||
try {
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
const res = await tool.execute("call-noisy", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
lobsterPath: "./lobster",
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
expect(res.details).toMatchObject({ ok: true, status: "ok" });
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
it("requires absolute lobsterPath when provided (even though it is ignored)", async () => {
|
||||
const fake = await writeFakeLobster({
|
||||
payload: { ok: true, status: "ok", output: [{ hello: "world" }], requiresApproval: null },
|
||||
});
|
||||
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = `${fake.dir}${path.delimiter}${originalPath ?? ""}`;
|
||||
|
||||
try {
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
await expect(
|
||||
tool.execute("call2", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
lobsterPath: "./lobster",
|
||||
}),
|
||||
).rejects.toThrow(/absolute path/);
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects lobsterPath (deprecated) when invalid", async () => {
|
||||
const fake = await writeFakeLobster({
|
||||
payload: { ok: true, status: "ok", output: [{ hello: "world" }], requiresApproval: null },
|
||||
});
|
||||
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = `${fake.dir}${path.delimiter}${originalPath ?? ""}`;
|
||||
|
||||
try {
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
await expect(
|
||||
tool.execute("call2b", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
lobsterPath: "/bin/bash",
|
||||
}),
|
||||
).rejects.toThrow(/lobster executable/);
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects absolute cwd", async () => {
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
await expect(
|
||||
tool.execute("call2c", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
cwd: "/tmp",
|
||||
}),
|
||||
).rejects.toThrow(/absolute path/);
|
||||
).rejects.toThrow(/cwd must be a relative path/);
|
||||
});
|
||||
|
||||
it("rejects cwd that escapes the gateway working directory", async () => {
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
await expect(
|
||||
tool.execute("call2d", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
cwd: "../../etc",
|
||||
}),
|
||||
).rejects.toThrow(/must stay within/);
|
||||
});
|
||||
|
||||
it("uses pluginConfig.lobsterPath when provided", async () => {
|
||||
const fake = await writeFakeLobster({
|
||||
payload: { ok: true, status: "ok", output: [{ hello: "world" }], requiresApproval: null },
|
||||
});
|
||||
|
||||
// Ensure `lobster` is NOT discoverable via PATH, while still allowing our
|
||||
// fake lobster (a Node script with `#!/usr/bin/env node`) to run.
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = path.dirname(process.execPath);
|
||||
|
||||
try {
|
||||
const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: fake.binPath } }));
|
||||
const res = await tool.execute("call-plugin-config", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
expect(res.details).toMatchObject({ ok: true, status: "ok" });
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid JSON from lobster", async () => {
|
||||
const { binPath } = await writeFakeLobsterScript(
|
||||
const { dir } = await writeFakeLobsterScript(
|
||||
`process.stdout.write("nope");\n`,
|
||||
"openclaw-lobster-plugin-bad-",
|
||||
);
|
||||
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ""}`;
|
||||
|
||||
try {
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
await expect(
|
||||
tool.execute("call3", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
}),
|
||||
).rejects.toThrow(/invalid JSON/);
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid JSON envelope from lobster", async () => {
|
||||
const { dir } = await writeFakeLobsterScript(
|
||||
`process.stdout.write(JSON.stringify({ hello: "world" }));\n`,
|
||||
"openclaw-lobster-plugin-bad-envelope-",
|
||||
);
|
||||
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ""}`;
|
||||
|
||||
try {
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
await expect(
|
||||
tool.execute("call3b", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
}),
|
||||
).rejects.toThrow(/invalid JSON envelope/);
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
it("requires action", async () => {
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
await expect(tool.execute("call-action-missing", {})).rejects.toThrow(/action required/);
|
||||
await expect(tool.execute("call-action-empty", { action: " " })).rejects.toThrow(
|
||||
/action required/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown action", async () => {
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
await expect(tool.execute("call-action-unknown", { action: "nope" })).rejects.toThrow(
|
||||
/Unknown action/,
|
||||
);
|
||||
});
|
||||
|
||||
it("validates run/resume parameters", async () => {
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
|
||||
await expect(tool.execute("call-run-missing-pipeline", { action: "run" })).rejects.toThrow(
|
||||
/pipeline required/,
|
||||
);
|
||||
await expect(
|
||||
tool.execute("call3", {
|
||||
tool.execute("call-run-empty-pipeline", { action: "run", pipeline: " " }),
|
||||
).rejects.toThrow(/pipeline required/);
|
||||
|
||||
await expect(tool.execute("call-resume-missing-token", { action: "resume" })).rejects.toThrow(
|
||||
/token required/,
|
||||
);
|
||||
await expect(
|
||||
tool.execute("call-resume-empty-token", { action: "resume", token: " ", approve: true }),
|
||||
).rejects.toThrow(/token required/);
|
||||
|
||||
await expect(
|
||||
tool.execute("call-resume-missing-approve", { action: "resume", token: "t" }),
|
||||
).rejects.toThrow(/approve required/);
|
||||
await expect(
|
||||
tool.execute("call-resume-non-boolean-approve", {
|
||||
action: "resume",
|
||||
token: "t",
|
||||
approve: "yes",
|
||||
}),
|
||||
).rejects.toThrow(/approve required/);
|
||||
});
|
||||
|
||||
it("rejects pluginConfig.lobsterPath when not absolute", async () => {
|
||||
const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: "./lobster" } }));
|
||||
await expect(
|
||||
tool.execute("call-plugin-config-relative", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
lobsterPath: binPath,
|
||||
}),
|
||||
).rejects.toThrow(/invalid JSON/);
|
||||
).rejects.toThrow(/absolute path/);
|
||||
});
|
||||
|
||||
it("rejects pluginConfig.lobsterPath when it does not exist", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-plugin-missing-"));
|
||||
const missingPath = path.join(dir, "lobster");
|
||||
|
||||
const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: missingPath } }));
|
||||
await expect(
|
||||
tool.execute("call-plugin-config-missing", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
}),
|
||||
).rejects.toThrow(/must exist/);
|
||||
});
|
||||
|
||||
it("rejects pluginConfig.lobsterPath when it points to a directory", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-plugin-dir-"));
|
||||
const lobsterDir = path.join(dir, "lobster");
|
||||
await fs.mkdir(lobsterDir);
|
||||
|
||||
const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: lobsterDir } }));
|
||||
await expect(
|
||||
tool.execute("call-plugin-config-dir", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
}),
|
||||
).rejects.toThrow(/point to a file/);
|
||||
});
|
||||
|
||||
it("rejects pluginConfig.lobsterPath when it is not executable (posix)", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-plugin-nonexec-"));
|
||||
const binPath = path.join(dir, "lobster");
|
||||
await fs.writeFile(binPath, "#!/usr/bin/env node\nprocess.stdout.write('[]')\n", {
|
||||
encoding: "utf8",
|
||||
mode: 0o644,
|
||||
});
|
||||
|
||||
const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: binPath } }));
|
||||
await expect(
|
||||
tool.execute("call-plugin-config-nonexec", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
}),
|
||||
).rejects.toThrow(/executable/);
|
||||
});
|
||||
|
||||
it("trims pluginConfig.lobsterPath", async () => {
|
||||
const fake = await writeFakeLobster({
|
||||
payload: { ok: true, status: "ok", output: [], requiresApproval: null },
|
||||
});
|
||||
|
||||
// Ensure `lobster` is NOT discoverable via PATH, while still allowing our
|
||||
// fake lobster (a Node script with `#!/usr/bin/env node`) to run.
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = path.dirname(process.execPath);
|
||||
|
||||
try {
|
||||
const tool = createLobsterTool(
|
||||
fakeApi({ pluginConfig: { lobsterPath: ` ${fake.binPath} ` } }),
|
||||
);
|
||||
const res = await tool.execute("call-plugin-config-trim", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
expect(res.details).toMatchObject({ ok: true, status: "ok" });
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores non-string pluginConfig.lobsterPath", async () => {
|
||||
const fake = await writeFakeLobster({
|
||||
payload: { ok: true, status: "ok", output: [], requiresApproval: null },
|
||||
});
|
||||
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = `${fake.dir}${path.delimiter}${originalPath ?? ""}`;
|
||||
|
||||
try {
|
||||
const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: 123 as any } }));
|
||||
const res = await tool.execute("call-plugin-config-non-string", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
expect(res.details).toMatchObject({ ok: true, status: "ok" });
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
it("validates deprecated lobsterPath even though it is ignored", async () => {
|
||||
const fake = await writeFakeLobster({
|
||||
payload: { ok: true, status: "ok", output: [], requiresApproval: null },
|
||||
});
|
||||
|
||||
// Ensure `lobster` is NOT discoverable via PATH, while still allowing our
|
||||
// fake lobster to run via plugin config.
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = path.dirname(process.execPath);
|
||||
|
||||
try {
|
||||
const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: fake.binPath } }));
|
||||
await expect(
|
||||
tool.execute("call-deprecated-invalid-with-plugin-config", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
lobsterPath: "/bin/bash",
|
||||
timeoutMs: 1000,
|
||||
}),
|
||||
).rejects.toThrow(/lobster executable/);
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects lobsterPath injection attempts", async () => {
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
await expect(
|
||||
tool.execute("call-lobsterpath-injection", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
lobsterPath: "/tmp/lobster --help",
|
||||
}),
|
||||
).rejects.toThrow(/lobster executable/);
|
||||
});
|
||||
|
||||
it("defaults cwd when empty or non-string", async () => {
|
||||
const payload = {
|
||||
ok: true,
|
||||
status: "ok",
|
||||
output: [{ cwd: "__REPLACED__" }],
|
||||
requiresApproval: null,
|
||||
};
|
||||
|
||||
const { dir } = await writeFakeLobsterScript(
|
||||
`const payload = ${JSON.stringify(payload)};\n` +
|
||||
`payload.output[0].cwd = process.cwd();\n` +
|
||||
`process.stdout.write(JSON.stringify(payload));\n`,
|
||||
"openclaw-lobster-plugin-cwd-default-",
|
||||
);
|
||||
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ""}`;
|
||||
|
||||
try {
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
const res1 = await tool.execute("call-cwd-empty", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
cwd: " ",
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
expect((res1.details as any).output[0].cwd).toBe(process.cwd());
|
||||
|
||||
const res2 = await tool.execute("call-cwd-non-string", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
cwd: 123,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
expect((res2.details as any).output[0].cwd).toBe(process.cwd());
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
it("uses trimmed relative cwd within the gateway working directory", async () => {
|
||||
const relDir = `.vitest-lobster-cwd-${Date.now()}`;
|
||||
const absDir = path.join(process.cwd(), relDir);
|
||||
await fs.mkdir(absDir);
|
||||
|
||||
const payload = {
|
||||
ok: true,
|
||||
status: "ok",
|
||||
output: [{ cwd: "__REPLACED__" }],
|
||||
requiresApproval: null,
|
||||
};
|
||||
|
||||
const { dir } = await writeFakeLobsterScript(
|
||||
`const payload = ${JSON.stringify(payload)};\n` +
|
||||
`payload.output[0].cwd = process.cwd();\n` +
|
||||
`process.stdout.write(JSON.stringify(payload));\n`,
|
||||
"openclaw-lobster-plugin-cwd-allowed-",
|
||||
);
|
||||
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ""}`;
|
||||
|
||||
try {
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
const res = await tool.execute("call-cwd-trim", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
cwd: ` ${relDir} `,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
expect((res.details as any).output[0].cwd).toBe(absDir);
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
await fs.rm(absDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects cwd that escapes via symlink", async () => {
|
||||
if (process.platform === "win32") {
|
||||
// Windows symlink creation can require elevated privileges in CI.
|
||||
return;
|
||||
}
|
||||
|
||||
const outside = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-plugin-outside-"));
|
||||
const linkName = `.vitest-lobster-symlink-${Date.now()}`;
|
||||
const linkPath = path.join(process.cwd(), linkName);
|
||||
|
||||
await fs.symlink(outside, linkPath, "dir");
|
||||
|
||||
try {
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
await expect(
|
||||
tool.execute("call-cwd-symlink-escape", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
cwd: linkName,
|
||||
}),
|
||||
).rejects.toThrow(/must stay within/);
|
||||
} finally {
|
||||
await fs.rm(linkPath, { recursive: true, force: true });
|
||||
await fs.rm(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("enforces maxStdoutBytes", async () => {
|
||||
const { dir } = await writeFakeLobsterScript(
|
||||
`process.stdout.write("x".repeat(20_000));\n`,
|
||||
"openclaw-lobster-plugin-stdout-limit-",
|
||||
);
|
||||
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ""}`;
|
||||
|
||||
try {
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
await expect(
|
||||
tool.execute("call-stdout-limit", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
timeoutMs: 2000,
|
||||
maxStdoutBytes: 1024,
|
||||
}),
|
||||
).rejects.toThrow(/maxStdoutBytes/);
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
it("times out lobster subprocess", async () => {
|
||||
const { dir } = await writeFakeLobsterScript(
|
||||
`setTimeout(() => {}, 10_000);\n`,
|
||||
"openclaw-lobster-plugin-timeout-",
|
||||
);
|
||||
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ""}`;
|
||||
|
||||
try {
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
await expect(
|
||||
tool.execute("call-timeout", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
timeoutMs: 250,
|
||||
}),
|
||||
).rejects.toThrow(/timed out/);
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
it("removes NODE_OPTIONS containing --inspect from child env", async () => {
|
||||
const payload = {
|
||||
ok: true,
|
||||
status: "ok",
|
||||
output: [{ nodeOptions: "__REPLACED__" }],
|
||||
requiresApproval: null,
|
||||
};
|
||||
|
||||
const { dir } = await writeFakeLobsterScript(
|
||||
`const payload = ${JSON.stringify(payload)};\n` +
|
||||
`payload.output[0].nodeOptions = process.env.NODE_OPTIONS ?? null;\n` +
|
||||
`process.stdout.write(JSON.stringify(payload));\n`,
|
||||
"openclaw-lobster-plugin-node-options-",
|
||||
);
|
||||
|
||||
const originalPath = process.env.PATH;
|
||||
const originalNodeOptions = process.env.NODE_OPTIONS;
|
||||
process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ""}`;
|
||||
process.env.NODE_OPTIONS = "--inspect=0";
|
||||
|
||||
try {
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
const res = await tool.execute("call-node-options", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
expect((res.details as any).output[0].nodeOptions).toBeNull();
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
process.env.NODE_OPTIONS = originalNodeOptions;
|
||||
}
|
||||
});
|
||||
|
||||
it("runs on Windows when lobster is only available as lobster.cmd on PATH (shell fallback)", async () => {
|
||||
if (process.platform !== "win32") {
|
||||
return;
|
||||
}
|
||||
|
||||
const fake = await writeFakeLobster({
|
||||
payload: { ok: true, status: "ok", output: [{ hello: "win" }], requiresApproval: null },
|
||||
});
|
||||
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = `${fake.dir}${path.delimiter}${originalPath ?? ""}`;
|
||||
|
||||
try {
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
const res = await tool.execute("call-win-shell-fallback", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
timeoutMs: 2000,
|
||||
});
|
||||
|
||||
expect(res.details).toMatchObject({ ok: true, status: "ok" });
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
it("can be gated off in sandboxed contexts", async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import type { OpenClawPluginApi } from "../../../src/plugins/types.js";
|
||||
@@ -23,18 +24,99 @@ type LobsterEnvelope =
|
||||
|
||||
function resolveExecutablePath(lobsterPathRaw: string | undefined) {
|
||||
const lobsterPath = lobsterPathRaw?.trim() || "lobster";
|
||||
if (lobsterPath !== "lobster" && !path.isAbsolute(lobsterPath)) {
|
||||
throw new Error("lobsterPath must be an absolute path (or omit to use PATH)");
|
||||
|
||||
// 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 isWindowsSpawnEINVAL(err: unknown) {
|
||||
function normalizeForCwdSandbox(p: string): string {
|
||||
const normalized = path.normalize(p);
|
||||
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
function resolveCwd(cwdRaw: unknown): string {
|
||||
if (typeof cwdRaw !== "string" || !cwdRaw.trim()) {
|
||||
return process.cwd();
|
||||
}
|
||||
const cwd = cwdRaw.trim();
|
||||
if (path.isAbsolute(cwd)) {
|
||||
throw new Error("cwd must be a relative path");
|
||||
}
|
||||
const base = process.cwd();
|
||||
const resolved = path.resolve(base, cwd);
|
||||
|
||||
const rel = path.relative(normalizeForCwdSandbox(base), normalizeForCwdSandbox(resolved));
|
||||
if (rel === "" || rel === ".") {
|
||||
return resolved;
|
||||
}
|
||||
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
||||
throw new Error("cwd must stay within the gateway working directory");
|
||||
}
|
||||
|
||||
// SECURITY: prevent escapes via symlinks that point outside the base dir.
|
||||
// If the path exists, compare its realpath with the base realpath.
|
||||
if (fs.existsSync(resolved)) {
|
||||
let baseReal: string;
|
||||
let resolvedReal: string;
|
||||
try {
|
||||
baseReal = fs.realpathSync(base);
|
||||
resolvedReal = fs.realpathSync(resolved);
|
||||
} catch {
|
||||
throw new Error("cwd must stay within the gateway working directory");
|
||||
}
|
||||
|
||||
const relReal = path.relative(
|
||||
normalizeForCwdSandbox(baseReal),
|
||||
normalizeForCwdSandbox(resolvedReal),
|
||||
);
|
||||
if (relReal.startsWith("..") || path.isAbsolute(relReal)) {
|
||||
throw new Error("cwd must stay within the gateway working directory");
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function isWindowsSpawnErrorThatCanUseShell(err: unknown) {
|
||||
if (!err || typeof err !== "object") {
|
||||
return false;
|
||||
}
|
||||
const code = (err as { code?: unknown }).code;
|
||||
return code === "EINVAL";
|
||||
|
||||
// On Windows, spawning scripts discovered on PATH (e.g. lobster.cmd) can fail
|
||||
// with EINVAL, and PATH discovery itself can fail with ENOENT when the binary
|
||||
// is only available via PATHEXT/script wrappers.
|
||||
return code === "EINVAL" || code === "ENOENT";
|
||||
}
|
||||
|
||||
async function runLobsterSubprocessOnce(
|
||||
@@ -125,7 +207,7 @@ async function runLobsterSubprocess(params: {
|
||||
try {
|
||||
return await runLobsterSubprocessOnce(params, false);
|
||||
} catch (err) {
|
||||
if (process.platform === "win32" && isWindowsSpawnEINVAL(err)) {
|
||||
if (process.platform === "win32" && isWindowsSpawnErrorThatCanUseShell(err)) {
|
||||
return await runLobsterSubprocessOnce(params, true);
|
||||
}
|
||||
throw err;
|
||||
@@ -182,8 +264,17 @@ export function createLobsterTool(api: OpenClawPluginApi) {
|
||||
argsJson: Type.Optional(Type.String()),
|
||||
token: Type.Optional(Type.String()),
|
||||
approve: Type.Optional(Type.Boolean()),
|
||||
lobsterPath: Type.Optional(Type.String()),
|
||||
cwd: Type.Optional(Type.String()),
|
||||
// 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:
|
||||
"Relative working directory (optional). Must stay within the gateway working directory.",
|
||||
}),
|
||||
),
|
||||
timeoutMs: Type.Optional(Type.Number()),
|
||||
maxStdoutBytes: Type.Optional(Type.Number()),
|
||||
}),
|
||||
@@ -193,11 +284,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 params.lobsterPath === "string" ? params.lobsterPath : undefined,
|
||||
typeof api.pluginConfig?.lobsterPath === "string"
|
||||
? api.pluginConfig.lobsterPath
|
||||
: undefined,
|
||||
);
|
||||
const cwd =
|
||||
typeof params.cwd === "string" && params.cwd.trim() ? params.cwd.trim() : process.cwd();
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user