@openclaw/lobster 2026.1.29 → 2026.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@ Adds the `lobster` agent tool as an **optional** plugin tool.
5
5
  ## What this is
6
6
 
7
7
  - Lobster is a standalone workflow shell (typed JSON-first pipelines + approvals/resume).
8
- - This plugin integrates Lobster with OpenClaw *without core changes*.
8
+ - This plugin integrates Lobster with OpenClaw _without core changes_.
9
9
 
10
10
  ## Enable
11
11
 
@@ -53,22 +53,17 @@ Example (allow only a small set of tools):
53
53
  {
54
54
  "id": "main",
55
55
  "tools": {
56
- "allow": [
57
- "lobster",
58
- "web_fetch",
59
- "web_search",
60
- "gog",
61
- "gh"
62
- ],
63
- "deny": ["gateway"]
64
- }
65
- }
66
- ]
67
- }
56
+ "allow": ["lobster", "web_fetch", "web_search", "gog", "gh"],
57
+ "deny": ["gateway"],
58
+ },
59
+ },
60
+ ],
61
+ },
68
62
  }
69
63
  ```
70
64
 
71
65
  Notes:
66
+
72
67
  - If `tools.allow` is omitted or empty, it behaves like "allow everything (except denied)". For a real allowlist, set a **non-empty** `allow`.
73
68
  - Tool names depend on which plugins you have installed/enabled.
74
69
 
package/SKILL.md CHANGED
@@ -8,13 +8,13 @@ Lobster executes multi-step workflows with approval checkpoints. Use it when:
8
8
 
9
9
  ## When to use Lobster
10
10
 
11
- | User intent | Use Lobster? |
12
- |-------------|--------------|
13
- | "Triage my email" | Yes — multi-step, may send replies |
14
- | "Send a message" | No — single action, use message tool directly |
15
- | "Check my email every morning and ask before replying" | Yes — scheduled workflow with approval |
16
- | "What's the weather?" | No — simple query |
17
- | "Monitor this PR and notify me of changes" | Yes — stateful, recurring |
11
+ | User intent | Use Lobster? |
12
+ | ------------------------------------------------------ | --------------------------------------------- |
13
+ | "Triage my email" | Yes — multi-step, may send replies |
14
+ | "Send a message" | No — single action, use message tool directly |
15
+ | "Check my email every morning and ask before replying" | Yes — scheduled workflow with approval |
16
+ | "What's the weather?" | No — simple query |
17
+ | "Monitor this PR and notify me of changes" | Yes — stateful, recurring |
18
18
 
19
19
  ## Basic usage
20
20
 
@@ -28,6 +28,7 @@ Lobster executes multi-step workflows with approval checkpoints. Use it when:
28
28
  ```
29
29
 
30
30
  Returns structured result:
31
+
31
32
  ```json
32
33
  {
33
34
  "protocolVersion": 1,
@@ -41,6 +42,7 @@ Returns structured result:
41
42
  ### Handle approval
42
43
 
43
44
  If the workflow needs approval:
45
+
44
46
  ```json
45
47
  {
46
48
  "status": "needs_approval",
@@ -54,6 +56,7 @@ If the workflow needs approval:
54
56
  ```
55
57
 
56
58
  Present the prompt to the user. If they approve:
59
+
57
60
  ```json
58
61
  {
59
62
  "action": "resume",
@@ -65,15 +68,19 @@ Present the prompt to the user. If they approve:
65
68
  ## Example workflows
66
69
 
67
70
  ### Email triage
71
+
68
72
  ```
69
73
  gog.gmail.search --query 'newer_than:1d' --max 20 | email.triage
70
74
  ```
75
+
71
76
  Fetches recent emails, classifies into buckets (needs_reply, needs_action, fyi).
72
77
 
73
78
  ### Email triage with approval gate
79
+
74
80
  ```
75
81
  gog.gmail.search --query 'newer_than:1d' | email.triage | approve --prompt 'Process these?'
76
82
  ```
83
+
77
84
  Same as above, but halts for approval before returning.
78
85
 
79
86
  ## Key behaviors
package/index.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import type { OpenClawPluginApi } from "../../src/plugins/types.js";
2
-
3
2
  import { createLobsterTool } from "./src/lobster-tool.js";
4
3
 
5
4
  export default function register(api: OpenClawPluginApi) {
6
5
  api.registerTool(
7
6
  (ctx) => {
8
- if (ctx.sandboxed) return null;
7
+ if (ctx.sandboxed) {
8
+ return null;
9
+ }
9
10
  return createLobsterTool(api);
10
11
  },
11
12
  { optional: true },
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@openclaw/lobster",
3
- "version": "2026.1.29",
4
- "type": "module",
3
+ "version": "2026.2.2",
5
4
  "description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
5
+ "type": "module",
6
+ "devDependencies": {
7
+ "openclaw": "workspace:*"
8
+ },
6
9
  "openclaw": {
7
10
  "extensions": [
8
11
  "./index.ts"
@@ -1,9 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
-
5
4
  import { describe, expect, it } from "vitest";
6
-
7
5
  import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../../../src/plugins/types.js";
8
6
  import { createLobsterTool } from "./lobster-tool.js";
9
7
 
@@ -33,12 +31,14 @@ async function writeFakeLobster(params: { payload: unknown }) {
33
31
  return await writeFakeLobsterScript(scriptBody);
34
32
  }
35
33
 
36
- function fakeApi(): OpenClawPluginApi {
34
+ function fakeApi(overrides: Partial<OpenClawPluginApi> = {}): OpenClawPluginApi {
37
35
  return {
38
36
  id: "lobster",
39
37
  name: "lobster",
40
38
  source: "test",
41
- config: {} as any,
39
+ config: {},
40
+ pluginConfig: {},
41
+ // oxlint-disable-next-line typescript/no-explicit-any
42
42
  runtime: { version: "test" } as any,
43
43
  logger: { info() {}, warn() {}, error() {}, debug() {} },
44
44
  registerTool() {},
@@ -48,13 +48,18 @@ function fakeApi(): OpenClawPluginApi {
48
48
  registerCli() {},
49
49
  registerService() {},
50
50
  registerProvider() {},
51
+ registerHook() {},
52
+ registerHttpRoute() {},
53
+ registerCommand() {},
54
+ on() {},
51
55
  resolvePath: (p) => p,
56
+ ...overrides,
52
57
  };
53
58
  }
54
59
 
55
60
  function fakeCtx(overrides: Partial<OpenClawPluginToolContext> = {}): OpenClawPluginToolContext {
56
61
  return {
57
- config: {} as any,
62
+ config: {},
58
63
  workspaceDir: "/tmp",
59
64
  agentDir: "/tmp",
60
65
  agentId: "main",
@@ -72,68 +77,167 @@ describe("lobster plugin tool", () => {
72
77
  payload: { ok: true, status: "ok", output: [{ hello: "world" }], requiresApproval: null },
73
78
  });
74
79
 
75
- const tool = createLobsterTool(fakeApi());
76
- const res = await tool.execute("call1", {
77
- action: "run",
78
- pipeline: "noop",
79
- lobsterPath: fake.binPath,
80
- timeoutMs: 1000,
81
- });
80
+ const originalPath = process.env.PATH;
81
+ process.env.PATH = `${fake.dir}${path.delimiter}${originalPath ?? ""}`;
82
+
83
+ try {
84
+ const tool = createLobsterTool(fakeApi());
85
+ const res = await tool.execute("call1", {
86
+ action: "run",
87
+ pipeline: "noop",
88
+ timeoutMs: 1000,
89
+ });
82
90
 
83
- expect(res.details).toMatchObject({ ok: true, status: "ok" });
91
+ expect(res.details).toMatchObject({ ok: true, status: "ok" });
92
+ } finally {
93
+ process.env.PATH = originalPath;
94
+ }
84
95
  });
85
96
 
86
97
  it("tolerates noisy stdout before the JSON envelope", async () => {
87
98
  const payload = { ok: true, status: "ok", output: [], requiresApproval: null };
88
- const { binPath } = await writeFakeLobsterScript(
99
+ const { dir } = await writeFakeLobsterScript(
89
100
  `const payload = ${JSON.stringify(payload)};\n` +
90
101
  `console.log("noise before json");\n` +
91
102
  `process.stdout.write(JSON.stringify(payload));\n`,
92
103
  "openclaw-lobster-plugin-noisy-",
93
104
  );
94
105
 
95
- const tool = createLobsterTool(fakeApi());
96
- const res = await tool.execute("call-noisy", {
97
- action: "run",
98
- pipeline: "noop",
99
- lobsterPath: binPath,
100
- timeoutMs: 1000,
106
+ const originalPath = process.env.PATH;
107
+ process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ""}`;
108
+
109
+ try {
110
+ const tool = createLobsterTool(fakeApi());
111
+ const res = await tool.execute("call-noisy", {
112
+ action: "run",
113
+ pipeline: "noop",
114
+ timeoutMs: 1000,
115
+ });
116
+
117
+ expect(res.details).toMatchObject({ ok: true, status: "ok" });
118
+ } finally {
119
+ process.env.PATH = originalPath;
120
+ }
121
+ });
122
+
123
+ it("requires absolute lobsterPath when provided (even though it is ignored)", async () => {
124
+ const fake = await writeFakeLobster({
125
+ payload: { ok: true, status: "ok", output: [{ hello: "world" }], requiresApproval: null },
126
+ });
127
+
128
+ const originalPath = process.env.PATH;
129
+ process.env.PATH = `${fake.dir}${path.delimiter}${originalPath ?? ""}`;
130
+
131
+ try {
132
+ const tool = createLobsterTool(fakeApi());
133
+ await expect(
134
+ tool.execute("call2", {
135
+ action: "run",
136
+ pipeline: "noop",
137
+ lobsterPath: "./lobster",
138
+ }),
139
+ ).rejects.toThrow(/absolute path/);
140
+ } finally {
141
+ process.env.PATH = originalPath;
142
+ }
143
+ });
144
+
145
+ it("rejects lobsterPath (deprecated) when invalid", async () => {
146
+ const fake = await writeFakeLobster({
147
+ payload: { ok: true, status: "ok", output: [{ hello: "world" }], requiresApproval: null },
101
148
  });
102
149
 
103
- expect(res.details).toMatchObject({ ok: true, status: "ok" });
150
+ const originalPath = process.env.PATH;
151
+ process.env.PATH = `${fake.dir}${path.delimiter}${originalPath ?? ""}`;
152
+
153
+ try {
154
+ const tool = createLobsterTool(fakeApi());
155
+ await expect(
156
+ tool.execute("call2b", {
157
+ action: "run",
158
+ pipeline: "noop",
159
+ lobsterPath: "/bin/bash",
160
+ }),
161
+ ).rejects.toThrow(/lobster executable/);
162
+ } finally {
163
+ process.env.PATH = originalPath;
164
+ }
104
165
  });
105
166
 
106
- it("requires absolute lobsterPath when provided", async () => {
167
+ it("rejects absolute cwd", async () => {
107
168
  const tool = createLobsterTool(fakeApi());
108
169
  await expect(
109
- tool.execute("call2", {
170
+ tool.execute("call2c", {
110
171
  action: "run",
111
172
  pipeline: "noop",
112
- lobsterPath: "./lobster",
173
+ cwd: "/tmp",
113
174
  }),
114
- ).rejects.toThrow(/absolute path/);
175
+ ).rejects.toThrow(/cwd must be a relative path/);
115
176
  });
116
177
 
117
- it("rejects invalid JSON from lobster", async () => {
118
- const { binPath } = await writeFakeLobsterScript(
119
- `process.stdout.write("nope");\n`,
120
- "openclaw-lobster-plugin-bad-",
121
- );
122
-
178
+ it("rejects cwd that escapes the gateway working directory", async () => {
123
179
  const tool = createLobsterTool(fakeApi());
124
180
  await expect(
125
- tool.execute("call3", {
181
+ tool.execute("call2d", {
126
182
  action: "run",
127
183
  pipeline: "noop",
128
- lobsterPath: binPath,
184
+ cwd: "../../etc",
129
185
  }),
130
- ).rejects.toThrow(/invalid JSON/);
186
+ ).rejects.toThrow(/must stay within/);
187
+ });
188
+
189
+ it("uses pluginConfig.lobsterPath when provided", async () => {
190
+ const fake = await writeFakeLobster({
191
+ payload: { ok: true, status: "ok", output: [{ hello: "world" }], requiresApproval: null },
192
+ });
193
+
194
+ // Ensure `lobster` is NOT discoverable via PATH, while still allowing our
195
+ // fake lobster (a Node script with `#!/usr/bin/env node`) to run.
196
+ const originalPath = process.env.PATH;
197
+ process.env.PATH = path.dirname(process.execPath);
198
+
199
+ try {
200
+ const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: fake.binPath } }));
201
+ const res = await tool.execute("call-plugin-config", {
202
+ action: "run",
203
+ pipeline: "noop",
204
+ timeoutMs: 1000,
205
+ });
206
+
207
+ expect(res.details).toMatchObject({ ok: true, status: "ok" });
208
+ } finally {
209
+ process.env.PATH = originalPath;
210
+ }
211
+ });
212
+
213
+ it("rejects invalid JSON from lobster", async () => {
214
+ const { dir } = await writeFakeLobsterScript(
215
+ `process.stdout.write("nope");\n`,
216
+ "openclaw-lobster-plugin-bad-",
217
+ );
218
+
219
+ const originalPath = process.env.PATH;
220
+ process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ""}`;
221
+
222
+ try {
223
+ const tool = createLobsterTool(fakeApi());
224
+ await expect(
225
+ tool.execute("call3", {
226
+ action: "run",
227
+ pipeline: "noop",
228
+ }),
229
+ ).rejects.toThrow(/invalid JSON/);
230
+ } finally {
231
+ process.env.PATH = originalPath;
232
+ }
131
233
  });
132
234
 
133
235
  it("can be gated off in sandboxed contexts", async () => {
134
236
  const api = fakeApi();
135
237
  const factoryTool = (ctx: OpenClawPluginToolContext) => {
136
- if (ctx.sandboxed) return null;
238
+ if (ctx.sandboxed) {
239
+ return null;
240
+ }
137
241
  return createLobsterTool(api);
138
242
  };
139
243
 
@@ -1,7 +1,7 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  import { spawn } from "node:child_process";
3
+ import fs from "node:fs";
3
4
  import path from "node:path";
4
-
5
5
  import type { OpenClawPluginApi } from "../../../src/plugins/types.js";
6
6
 
7
7
  type LobsterEnvelope =
@@ -23,16 +23,77 @@ type LobsterEnvelope =
23
23
 
24
24
  function resolveExecutablePath(lobsterPathRaw: string | undefined) {
25
25
  const lobsterPath = lobsterPathRaw?.trim() || "lobster";
26
- if (lobsterPath !== "lobster" && !path.isAbsolute(lobsterPath)) {
27
- throw new Error("lobsterPath must be an absolute path (or omit to use PATH)");
26
+
27
+ // SECURITY:
28
+ // Never allow arbitrary executables (e.g. /bin/bash). If the caller overrides
29
+ // the path, it must still be the lobster binary (by name) and be absolute.
30
+ if (lobsterPath !== "lobster") {
31
+ if (!path.isAbsolute(lobsterPath)) {
32
+ throw new Error("lobsterPath must be an absolute path (or omit to use PATH)");
33
+ }
34
+ const base = path.basename(lobsterPath).toLowerCase();
35
+ const allowed =
36
+ process.platform === "win32" ? ["lobster.exe", "lobster.cmd", "lobster.bat"] : ["lobster"];
37
+ if (!allowed.includes(base)) {
38
+ throw new Error("lobsterPath must point to the lobster executable");
39
+ }
40
+ let stat: fs.Stats;
41
+ try {
42
+ stat = fs.statSync(lobsterPath);
43
+ } catch {
44
+ throw new Error("lobsterPath must exist");
45
+ }
46
+ if (!stat.isFile()) {
47
+ throw new Error("lobsterPath must point to a file");
48
+ }
49
+ if (process.platform !== "win32") {
50
+ try {
51
+ fs.accessSync(lobsterPath, fs.constants.X_OK);
52
+ } catch {
53
+ throw new Error("lobsterPath must be executable");
54
+ }
55
+ }
28
56
  }
57
+
29
58
  return lobsterPath;
30
59
  }
31
60
 
32
- function isWindowsSpawnEINVAL(err: unknown) {
33
- if (!err || typeof err !== "object") return false;
61
+ function normalizeForCwdSandbox(p: string): string {
62
+ const normalized = path.normalize(p);
63
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
64
+ }
65
+
66
+ function resolveCwd(cwdRaw: unknown): string {
67
+ if (typeof cwdRaw !== "string" || !cwdRaw.trim()) {
68
+ return process.cwd();
69
+ }
70
+ const cwd = cwdRaw.trim();
71
+ if (path.isAbsolute(cwd)) {
72
+ throw new Error("cwd must be a relative path");
73
+ }
74
+ const base = process.cwd();
75
+ const resolved = path.resolve(base, cwd);
76
+
77
+ const rel = path.relative(normalizeForCwdSandbox(base), normalizeForCwdSandbox(resolved));
78
+ if (rel === "" || rel === ".") {
79
+ return resolved;
80
+ }
81
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
82
+ throw new Error("cwd must stay within the gateway working directory");
83
+ }
84
+ return resolved;
85
+ }
86
+
87
+ function isWindowsSpawnErrorThatCanUseShell(err: unknown) {
88
+ if (!err || typeof err !== "object") {
89
+ return false;
90
+ }
34
91
  const code = (err as { code?: unknown }).code;
35
- return code === "EINVAL";
92
+
93
+ // On Windows, spawning scripts discovered on PATH (e.g. lobster.cmd) can fail
94
+ // with EINVAL, and PATH discovery itself can fail with ENOENT when the binary
95
+ // is only available via PATHEXT/script wrappers.
96
+ return code === "EINVAL" || code === "ENOENT";
36
97
  }
37
98
 
38
99
  async function runLobsterSubprocessOnce(
@@ -123,7 +184,7 @@ async function runLobsterSubprocess(params: {
123
184
  try {
124
185
  return await runLobsterSubprocessOnce(params, false);
125
186
  } catch (err) {
126
- if (process.platform === "win32" && isWindowsSpawnEINVAL(err)) {
187
+ if (process.platform === "win32" && isWindowsSpawnErrorThatCanUseShell(err)) {
127
188
  return await runLobsterSubprocessOnce(params, true);
128
189
  }
129
190
  throw err;
@@ -180,26 +241,49 @@ export function createLobsterTool(api: OpenClawPluginApi) {
180
241
  argsJson: Type.Optional(Type.String()),
181
242
  token: Type.Optional(Type.String()),
182
243
  approve: Type.Optional(Type.Boolean()),
183
- lobsterPath: Type.Optional(Type.String()),
184
- cwd: Type.Optional(Type.String()),
244
+ // SECURITY: Do not allow the agent to choose an executable path.
245
+ // Host can configure the lobster binary via plugin config.
246
+ lobsterPath: Type.Optional(
247
+ Type.String({ description: "(deprecated) Use plugin config instead." }),
248
+ ),
249
+ cwd: Type.Optional(
250
+ Type.String({
251
+ description:
252
+ "Relative working directory (optional). Must stay within the gateway working directory.",
253
+ }),
254
+ ),
185
255
  timeoutMs: Type.Optional(Type.Number()),
186
256
  maxStdoutBytes: Type.Optional(Type.Number()),
187
257
  }),
188
258
  async execute(_id: string, params: Record<string, unknown>) {
189
- const action = String(params.action || "").trim();
190
- if (!action) throw new Error("action required");
259
+ const action = typeof params.action === "string" ? params.action.trim() : "";
260
+ if (!action) {
261
+ throw new Error("action required");
262
+ }
263
+
264
+ // SECURITY: never allow tool callers (agent/user) to select executables.
265
+ // If a host needs to override the binary, it must do so via plugin config.
266
+ // We still validate the parameter shape to prevent reintroducing an RCE footgun.
267
+ if (typeof params.lobsterPath === "string" && params.lobsterPath.trim()) {
268
+ resolveExecutablePath(params.lobsterPath);
269
+ }
191
270
 
192
271
  const execPath = resolveExecutablePath(
193
- typeof params.lobsterPath === "string" ? params.lobsterPath : undefined,
272
+ typeof api.pluginConfig?.lobsterPath === "string"
273
+ ? api.pluginConfig.lobsterPath
274
+ : undefined,
194
275
  );
195
- const cwd = typeof params.cwd === "string" && params.cwd.trim() ? params.cwd.trim() : process.cwd();
276
+ const cwd = resolveCwd(params.cwd);
196
277
  const timeoutMs = typeof params.timeoutMs === "number" ? params.timeoutMs : 20_000;
197
- const maxStdoutBytes = typeof params.maxStdoutBytes === "number" ? params.maxStdoutBytes : 512_000;
278
+ const maxStdoutBytes =
279
+ typeof params.maxStdoutBytes === "number" ? params.maxStdoutBytes : 512_000;
198
280
 
199
281
  const argv = (() => {
200
282
  if (action === "run") {
201
283
  const pipeline = typeof params.pipeline === "string" ? params.pipeline : "";
202
- if (!pipeline.trim()) throw new Error("pipeline required");
284
+ if (!pipeline.trim()) {
285
+ throw new Error("pipeline required");
286
+ }
203
287
  const argv = ["run", "--mode", "tool", pipeline];
204
288
  const argsJson = typeof params.argsJson === "string" ? params.argsJson : "";
205
289
  if (argsJson.trim()) {
@@ -209,9 +293,13 @@ export function createLobsterTool(api: OpenClawPluginApi) {
209
293
  }
210
294
  if (action === "resume") {
211
295
  const token = typeof params.token === "string" ? params.token : "";
212
- if (!token.trim()) throw new Error("token required");
296
+ if (!token.trim()) {
297
+ throw new Error("token required");
298
+ }
213
299
  const approve = params.approve;
214
- if (typeof approve !== "boolean") throw new Error("approve required");
300
+ if (typeof approve !== "boolean") {
301
+ throw new Error("approve required");
302
+ }
215
303
  return ["resume", "--token", token, "--approve", approve ? "yes" : "no"];
216
304
  }
217
305
  throw new Error(`Unknown action: ${action}`);