@openclaw/lobster 2026.3.13 → 2026.5.1-beta.1
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 +1 -2
- package/index.ts +22 -16
- package/openclaw.plugin.json +3 -0
- package/package.json +22 -3
- package/runtime-api.ts +12 -0
- package/src/lobster-ajv-cache.ts +142 -0
- package/src/lobster-core.d.ts +60 -0
- package/src/lobster-runner.test.ts +572 -0
- package/src/lobster-runner.ts +395 -0
- package/src/lobster-taskflow.test.ts +227 -0
- package/src/lobster-taskflow.ts +279 -0
- package/src/lobster-tool.test.ts +250 -208
- package/src/lobster-tool.ts +245 -191
- package/src/taskflow-test-helpers.ts +48 -0
- package/tsconfig.json +16 -0
- package/src/test-helpers.ts +0 -43
- package/src/windows-spawn.test.ts +0 -118
- package/src/windows-spawn.ts +0 -36
package/src/lobster-tool.test.ts
CHANGED
|
@@ -1,56 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk/lobster";
|
|
7
|
-
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
|
-
import {
|
|
9
|
-
createWindowsCmdShimFixture,
|
|
10
|
-
restorePlatformPathEnv,
|
|
11
|
-
setProcessPlatform,
|
|
12
|
-
snapshotPlatformPathEnv,
|
|
13
|
-
} from "./test-helpers.js";
|
|
14
|
-
|
|
15
|
-
const spawnState = vi.hoisted(() => ({
|
|
16
|
-
queue: [] as Array<{ stdout: string; stderr?: string; exitCode?: number }>,
|
|
17
|
-
spawn: vi.fn(),
|
|
18
|
-
}));
|
|
19
|
-
|
|
20
|
-
vi.mock("node:child_process", async (importOriginal) => {
|
|
21
|
-
const actual = await importOriginal<typeof import("node:child_process")>();
|
|
22
|
-
return {
|
|
23
|
-
...actual,
|
|
24
|
-
spawn: (...args: unknown[]) => spawnState.spawn(...args),
|
|
25
|
-
};
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
let createLobsterTool: typeof import("./lobster-tool.js").createLobsterTool;
|
|
1
|
+
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../runtime-api.js";
|
|
4
|
+
import { createLobsterTool } from "./lobster-tool.js";
|
|
5
|
+
import { createFakeTaskFlow } from "./taskflow-test-helpers.js";
|
|
29
6
|
|
|
30
7
|
function fakeApi(overrides: Partial<OpenClawPluginApi> = {}): OpenClawPluginApi {
|
|
31
|
-
return {
|
|
8
|
+
return createTestPluginApi({
|
|
32
9
|
id: "lobster",
|
|
33
10
|
name: "lobster",
|
|
34
11
|
source: "test",
|
|
35
|
-
config: {},
|
|
36
|
-
pluginConfig: {},
|
|
37
|
-
// oxlint-disable-next-line typescript/no-explicit-any
|
|
38
12
|
runtime: { version: "test" } as any,
|
|
39
|
-
logger: { info() {}, warn() {}, error() {}, debug() {} },
|
|
40
|
-
registerTool() {},
|
|
41
|
-
registerChannel() {},
|
|
42
|
-
registerGatewayMethod() {},
|
|
43
|
-
registerCli() {},
|
|
44
|
-
registerService() {},
|
|
45
|
-
registerProvider() {},
|
|
46
|
-
registerHook() {},
|
|
47
|
-
registerHttpRoute() {},
|
|
48
|
-
registerCommand() {},
|
|
49
|
-
registerContextEngine() {},
|
|
50
|
-
on() {},
|
|
51
13
|
resolvePath: (p) => p,
|
|
52
14
|
...overrides,
|
|
53
|
-
};
|
|
15
|
+
});
|
|
54
16
|
}
|
|
55
17
|
|
|
56
18
|
function fakeCtx(overrides: Partial<OpenClawPluginToolContext> = {}): OpenClawPluginToolContext {
|
|
@@ -68,140 +30,279 @@ function fakeCtx(overrides: Partial<OpenClawPluginToolContext> = {}): OpenClawPl
|
|
|
68
30
|
}
|
|
69
31
|
|
|
70
32
|
describe("lobster plugin tool", () => {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
beforeAll(async () => {
|
|
75
|
-
({ createLobsterTool } = await import("./lobster-tool.js"));
|
|
76
|
-
|
|
77
|
-
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-plugin-"));
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
afterEach(() => {
|
|
81
|
-
restorePlatformPathEnv(originalProcessState);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
afterAll(async () => {
|
|
85
|
-
if (!tempDir) {
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
if (process.platform === "win32") {
|
|
89
|
-
await fs.rm(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 });
|
|
90
|
-
} else {
|
|
91
|
-
await fs.rm(tempDir, { recursive: true, force: true });
|
|
92
|
-
}
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
beforeEach(() => {
|
|
96
|
-
spawnState.queue.length = 0;
|
|
97
|
-
spawnState.spawn.mockReset();
|
|
98
|
-
spawnState.spawn.mockImplementation(() => {
|
|
99
|
-
const next = spawnState.queue.shift() ?? { stdout: "" };
|
|
100
|
-
const stdout = new PassThrough();
|
|
101
|
-
const stderr = new PassThrough();
|
|
102
|
-
const child = new EventEmitter() as EventEmitter & {
|
|
103
|
-
stdout: PassThrough;
|
|
104
|
-
stderr: PassThrough;
|
|
105
|
-
kill: (signal?: string) => boolean;
|
|
106
|
-
};
|
|
107
|
-
child.stdout = stdout;
|
|
108
|
-
child.stderr = stderr;
|
|
109
|
-
child.kill = () => true;
|
|
110
|
-
|
|
111
|
-
setImmediate(() => {
|
|
112
|
-
if (next.stderr) {
|
|
113
|
-
stderr.end(next.stderr);
|
|
114
|
-
} else {
|
|
115
|
-
stderr.end();
|
|
116
|
-
}
|
|
117
|
-
stdout.end(next.stdout);
|
|
118
|
-
child.emit("exit", next.exitCode ?? 0);
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
return child;
|
|
122
|
-
});
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
const queueSuccessfulEnvelope = (hello = "world") => {
|
|
126
|
-
spawnState.queue.push({
|
|
127
|
-
stdout: JSON.stringify({
|
|
33
|
+
it("returns the Lobster envelope in details", async () => {
|
|
34
|
+
const runner = {
|
|
35
|
+
run: vi.fn().mockResolvedValue({
|
|
128
36
|
ok: true,
|
|
129
37
|
status: "ok",
|
|
130
|
-
output: [{ hello }],
|
|
38
|
+
output: [{ hello: "world" }],
|
|
131
39
|
requiresApproval: null,
|
|
132
40
|
}),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const tool = createLobsterTool(fakeApi(), { runner });
|
|
44
|
+
const res = await tool.execute("call1", {
|
|
45
|
+
action: "run",
|
|
46
|
+
pipeline: "noop",
|
|
47
|
+
timeoutMs: 1000,
|
|
133
48
|
});
|
|
134
|
-
};
|
|
135
49
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
50
|
+
expect(runner.run).toHaveBeenCalledWith({
|
|
51
|
+
action: "run",
|
|
52
|
+
pipeline: "noop",
|
|
53
|
+
cwd: process.cwd(),
|
|
54
|
+
timeoutMs: 1000,
|
|
55
|
+
maxStdoutBytes: 512_000,
|
|
56
|
+
});
|
|
57
|
+
expect(res.details).toMatchObject({
|
|
58
|
+
ok: true,
|
|
59
|
+
status: "ok",
|
|
60
|
+
output: [{ hello: "world" }],
|
|
61
|
+
requiresApproval: null,
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("supports approval envelopes without changing the tool contract", async () => {
|
|
66
|
+
const runner = {
|
|
67
|
+
run: vi.fn().mockResolvedValue({
|
|
139
68
|
ok: true,
|
|
140
|
-
status: "
|
|
141
|
-
output: [
|
|
142
|
-
requiresApproval:
|
|
69
|
+
status: "needs_approval",
|
|
70
|
+
output: [],
|
|
71
|
+
requiresApproval: {
|
|
72
|
+
type: "approval_request",
|
|
73
|
+
prompt: "Send these alerts?",
|
|
74
|
+
items: [{ id: "alert-1" }],
|
|
75
|
+
resumeToken: "resume-token-1",
|
|
76
|
+
},
|
|
143
77
|
}),
|
|
144
|
-
}
|
|
78
|
+
};
|
|
145
79
|
|
|
146
|
-
const tool = createLobsterTool(fakeApi());
|
|
147
|
-
const res = await tool.execute("
|
|
80
|
+
const tool = createLobsterTool(fakeApi(), { runner });
|
|
81
|
+
const res = await tool.execute("call-injected-runner", {
|
|
148
82
|
action: "run",
|
|
149
83
|
pipeline: "noop",
|
|
150
|
-
|
|
84
|
+
argsJson: '{"since_hours":1}',
|
|
85
|
+
timeoutMs: 1500,
|
|
86
|
+
maxStdoutBytes: 4096,
|
|
151
87
|
});
|
|
152
88
|
|
|
153
|
-
expect(
|
|
154
|
-
|
|
89
|
+
expect(runner.run).toHaveBeenCalledWith({
|
|
90
|
+
action: "run",
|
|
91
|
+
pipeline: "noop",
|
|
92
|
+
argsJson: '{"since_hours":1}',
|
|
93
|
+
cwd: process.cwd(),
|
|
94
|
+
timeoutMs: 1500,
|
|
95
|
+
maxStdoutBytes: 4096,
|
|
96
|
+
});
|
|
97
|
+
expect(res.details).toMatchObject({
|
|
98
|
+
ok: true,
|
|
99
|
+
status: "needs_approval",
|
|
100
|
+
requiresApproval: {
|
|
101
|
+
type: "approval_request",
|
|
102
|
+
prompt: "Send these alerts?",
|
|
103
|
+
resumeToken: "resume-token-1",
|
|
104
|
+
},
|
|
105
|
+
});
|
|
155
106
|
});
|
|
156
107
|
|
|
157
|
-
it("
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
108
|
+
it("throws when the runner returns an error envelope", async () => {
|
|
109
|
+
const tool = createLobsterTool(fakeApi(), {
|
|
110
|
+
runner: {
|
|
111
|
+
run: vi.fn().mockResolvedValue({
|
|
112
|
+
ok: false,
|
|
113
|
+
error: {
|
|
114
|
+
type: "runtime_error",
|
|
115
|
+
message: "boom",
|
|
116
|
+
},
|
|
117
|
+
}),
|
|
118
|
+
},
|
|
161
119
|
});
|
|
162
120
|
|
|
163
|
-
|
|
164
|
-
|
|
121
|
+
await expect(
|
|
122
|
+
tool.execute("call-runner-error", {
|
|
123
|
+
action: "run",
|
|
124
|
+
pipeline: "noop",
|
|
125
|
+
}),
|
|
126
|
+
).rejects.toThrow("boom");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("can run through managed TaskFlow mode", async () => {
|
|
130
|
+
const runner = {
|
|
131
|
+
run: vi.fn().mockResolvedValue({
|
|
132
|
+
ok: true,
|
|
133
|
+
status: "needs_approval",
|
|
134
|
+
output: [],
|
|
135
|
+
requiresApproval: {
|
|
136
|
+
type: "approval_request",
|
|
137
|
+
prompt: "Approve this?",
|
|
138
|
+
items: [{ id: "item-1" }],
|
|
139
|
+
resumeToken: "resume-1",
|
|
140
|
+
approvalId: "approval-1",
|
|
141
|
+
},
|
|
142
|
+
}),
|
|
143
|
+
};
|
|
144
|
+
const taskFlow = createFakeTaskFlow();
|
|
145
|
+
|
|
146
|
+
const tool = createLobsterTool(fakeApi(), { runner, taskFlow });
|
|
147
|
+
const res = await tool.execute("call-managed-run", {
|
|
165
148
|
action: "run",
|
|
166
149
|
pipeline: "noop",
|
|
167
|
-
|
|
150
|
+
flowControllerId: "tests/lobster",
|
|
151
|
+
flowGoal: "Run Lobster workflow",
|
|
152
|
+
flowStateJson: '{"lane":"email"}',
|
|
153
|
+
flowCurrentStep: "run_lobster",
|
|
154
|
+
flowWaitingStep: "await_review",
|
|
168
155
|
});
|
|
169
156
|
|
|
170
|
-
expect(
|
|
157
|
+
expect(taskFlow.createManaged).toHaveBeenCalledWith({
|
|
158
|
+
controllerId: "tests/lobster",
|
|
159
|
+
goal: "Run Lobster workflow",
|
|
160
|
+
currentStep: "run_lobster",
|
|
161
|
+
stateJson: { lane: "email" },
|
|
162
|
+
});
|
|
163
|
+
expect(taskFlow.setWaiting).toHaveBeenCalledWith({
|
|
164
|
+
flowId: "flow-1",
|
|
165
|
+
expectedRevision: 1,
|
|
166
|
+
currentStep: "await_review",
|
|
167
|
+
waitJson: {
|
|
168
|
+
kind: "lobster_approval",
|
|
169
|
+
prompt: "Approve this?",
|
|
170
|
+
items: [{ id: "item-1" }],
|
|
171
|
+
resumeToken: "resume-1",
|
|
172
|
+
approvalId: "approval-1",
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
expect(res.details).toMatchObject({
|
|
176
|
+
ok: true,
|
|
177
|
+
status: "needs_approval",
|
|
178
|
+
flow: {
|
|
179
|
+
flowId: "flow-1",
|
|
180
|
+
},
|
|
181
|
+
mutation: {
|
|
182
|
+
applied: true,
|
|
183
|
+
},
|
|
184
|
+
});
|
|
171
185
|
});
|
|
172
186
|
|
|
173
|
-
it("
|
|
174
|
-
const tool = createLobsterTool(fakeApi()
|
|
175
|
-
|
|
187
|
+
it("rejects managed TaskFlow params when no bound taskFlow runtime is available", async () => {
|
|
188
|
+
const tool = createLobsterTool(fakeApi(), {
|
|
189
|
+
runner: { run: vi.fn() },
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
await expect(
|
|
193
|
+
tool.execute("call-missing-taskflow", {
|
|
194
|
+
action: "run",
|
|
195
|
+
pipeline: "noop",
|
|
196
|
+
flowControllerId: "tests/lobster",
|
|
197
|
+
flowGoal: "Run Lobster workflow",
|
|
198
|
+
}),
|
|
199
|
+
).rejects.toThrow(/Managed TaskFlow run mode requires a bound taskFlow runtime/);
|
|
176
200
|
});
|
|
177
201
|
|
|
178
|
-
it("
|
|
179
|
-
const tool = createLobsterTool(fakeApi()
|
|
202
|
+
it("rejects invalid flowStateJson in managed TaskFlow mode", async () => {
|
|
203
|
+
const tool = createLobsterTool(fakeApi(), {
|
|
204
|
+
runner: { run: vi.fn() },
|
|
205
|
+
taskFlow: createFakeTaskFlow(),
|
|
206
|
+
});
|
|
207
|
+
|
|
180
208
|
await expect(
|
|
181
|
-
tool.execute("call-
|
|
209
|
+
tool.execute("call-invalid-flow-json", {
|
|
182
210
|
action: "run",
|
|
211
|
+
pipeline: "noop",
|
|
212
|
+
flowControllerId: "tests/lobster",
|
|
213
|
+
flowGoal: "Run Lobster workflow",
|
|
214
|
+
flowStateJson: "{bad",
|
|
183
215
|
}),
|
|
184
|
-
).rejects.toThrow(/
|
|
216
|
+
).rejects.toThrow(/flowStateJson must be valid JSON/);
|
|
185
217
|
});
|
|
186
218
|
|
|
187
|
-
it("
|
|
188
|
-
const
|
|
219
|
+
it("can resume managed TaskFlow mode with only approvalId", async () => {
|
|
220
|
+
const runner = {
|
|
221
|
+
run: vi.fn().mockResolvedValue({
|
|
222
|
+
ok: true,
|
|
223
|
+
status: "ok",
|
|
224
|
+
output: [],
|
|
225
|
+
requiresApproval: null,
|
|
226
|
+
}),
|
|
227
|
+
};
|
|
228
|
+
const taskFlow = createFakeTaskFlow();
|
|
229
|
+
const tool = createLobsterTool(fakeApi(), { runner, taskFlow });
|
|
230
|
+
|
|
231
|
+
const res = await tool.execute("call-managed-resume-approval-id", {
|
|
232
|
+
action: "resume",
|
|
233
|
+
approvalId: "approval-1",
|
|
234
|
+
approve: true,
|
|
235
|
+
flowId: "flow-1",
|
|
236
|
+
flowExpectedRevision: 1,
|
|
237
|
+
flowCurrentStep: "resume_lobster",
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
expect(taskFlow.resume).toHaveBeenCalledWith({
|
|
241
|
+
flowId: "flow-1",
|
|
242
|
+
expectedRevision: 1,
|
|
243
|
+
status: "running",
|
|
244
|
+
currentStep: "resume_lobster",
|
|
245
|
+
});
|
|
246
|
+
expect(runner.run).toHaveBeenCalledWith({
|
|
247
|
+
action: "resume",
|
|
248
|
+
approvalId: "approval-1",
|
|
249
|
+
approve: true,
|
|
250
|
+
cwd: process.cwd(),
|
|
251
|
+
timeoutMs: 20_000,
|
|
252
|
+
maxStdoutBytes: 512_000,
|
|
253
|
+
});
|
|
254
|
+
expect(res.details).toMatchObject({
|
|
255
|
+
ok: true,
|
|
256
|
+
status: "ok",
|
|
257
|
+
mutation: {
|
|
258
|
+
applied: true,
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("rejects managed TaskFlow resume mode without a token or approvalId", async () => {
|
|
264
|
+
const tool = createLobsterTool(fakeApi(), {
|
|
265
|
+
runner: { run: vi.fn() },
|
|
266
|
+
taskFlow: createFakeTaskFlow(),
|
|
267
|
+
});
|
|
268
|
+
|
|
189
269
|
await expect(
|
|
190
|
-
tool.execute("call-resume-token
|
|
270
|
+
tool.execute("call-missing-resume-token", {
|
|
191
271
|
action: "resume",
|
|
272
|
+
flowId: "flow-1",
|
|
273
|
+
flowExpectedRevision: 1,
|
|
192
274
|
approve: true,
|
|
193
275
|
}),
|
|
194
|
-
).rejects.toThrow(/token required/);
|
|
276
|
+
).rejects.toThrow(/token or approvalId required when using managed TaskFlow resume mode/);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("rejects managed TaskFlow resume mode without approve", async () => {
|
|
280
|
+
const tool = createLobsterTool(fakeApi(), {
|
|
281
|
+
runner: { run: vi.fn() },
|
|
282
|
+
taskFlow: createFakeTaskFlow(),
|
|
283
|
+
});
|
|
284
|
+
|
|
195
285
|
await expect(
|
|
196
|
-
tool.execute("call-resume-approve
|
|
286
|
+
tool.execute("call-missing-resume-approve", {
|
|
197
287
|
action: "resume",
|
|
198
288
|
token: "resume-token",
|
|
289
|
+
flowId: "flow-1",
|
|
290
|
+
flowExpectedRevision: 1,
|
|
199
291
|
}),
|
|
200
|
-
).rejects.toThrow(/approve required/);
|
|
292
|
+
).rejects.toThrow(/approve required when using managed TaskFlow resume mode/);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("requires action", async () => {
|
|
296
|
+
const tool = createLobsterTool(fakeApi(), {
|
|
297
|
+
runner: { run: vi.fn() },
|
|
298
|
+
});
|
|
299
|
+
await expect(tool.execute("call-action-missing", {})).rejects.toThrow(/action required/);
|
|
201
300
|
});
|
|
202
301
|
|
|
203
302
|
it("rejects unknown action", async () => {
|
|
204
|
-
const tool = createLobsterTool(fakeApi()
|
|
303
|
+
const tool = createLobsterTool(fakeApi(), {
|
|
304
|
+
runner: { run: vi.fn() },
|
|
305
|
+
});
|
|
205
306
|
await expect(
|
|
206
307
|
tool.execute("call-action-unknown", {
|
|
207
308
|
action: "explode",
|
|
@@ -210,9 +311,11 @@ describe("lobster plugin tool", () => {
|
|
|
210
311
|
});
|
|
211
312
|
|
|
212
313
|
it("rejects absolute cwd", async () => {
|
|
213
|
-
const tool = createLobsterTool(fakeApi()
|
|
314
|
+
const tool = createLobsterTool(fakeApi(), {
|
|
315
|
+
runner: { run: vi.fn() },
|
|
316
|
+
});
|
|
214
317
|
await expect(
|
|
215
|
-
tool.execute("
|
|
318
|
+
tool.execute("call-absolute-cwd", {
|
|
216
319
|
action: "run",
|
|
217
320
|
pipeline: "noop",
|
|
218
321
|
cwd: "/tmp",
|
|
@@ -221,9 +324,11 @@ describe("lobster plugin tool", () => {
|
|
|
221
324
|
});
|
|
222
325
|
|
|
223
326
|
it("rejects cwd that escapes the gateway working directory", async () => {
|
|
224
|
-
const tool = createLobsterTool(fakeApi()
|
|
327
|
+
const tool = createLobsterTool(fakeApi(), {
|
|
328
|
+
runner: { run: vi.fn() },
|
|
329
|
+
});
|
|
225
330
|
await expect(
|
|
226
|
-
tool.execute("
|
|
331
|
+
tool.execute("call-escape-cwd", {
|
|
227
332
|
action: "run",
|
|
228
333
|
pipeline: "noop",
|
|
229
334
|
cwd: "../../etc",
|
|
@@ -231,78 +336,15 @@ describe("lobster plugin tool", () => {
|
|
|
231
336
|
).rejects.toThrow(/must stay within/);
|
|
232
337
|
});
|
|
233
338
|
|
|
234
|
-
it("rejects invalid JSON from lobster", async () => {
|
|
235
|
-
spawnState.queue.push({ stdout: "nope" });
|
|
236
|
-
|
|
237
|
-
const tool = createLobsterTool(fakeApi());
|
|
238
|
-
await expect(
|
|
239
|
-
tool.execute("call3", {
|
|
240
|
-
action: "run",
|
|
241
|
-
pipeline: "noop",
|
|
242
|
-
}),
|
|
243
|
-
).rejects.toThrow(/invalid JSON/);
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
it("runs Windows cmd shims through Node without enabling shell", async () => {
|
|
247
|
-
setProcessPlatform("win32");
|
|
248
|
-
const shimScriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs");
|
|
249
|
-
const shimPath = path.join(tempDir, "shim-bin", "lobster.cmd");
|
|
250
|
-
await createWindowsCmdShimFixture({
|
|
251
|
-
shimPath,
|
|
252
|
-
scriptPath: shimScriptPath,
|
|
253
|
-
shimLine: `"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`,
|
|
254
|
-
});
|
|
255
|
-
process.env.PATHEXT = ".CMD;.EXE";
|
|
256
|
-
process.env.PATH = `${path.dirname(shimPath)};${process.env.PATH ?? ""}`;
|
|
257
|
-
queueSuccessfulEnvelope();
|
|
258
|
-
|
|
259
|
-
const tool = createLobsterTool(fakeApi());
|
|
260
|
-
await tool.execute("call-win-shim", {
|
|
261
|
-
action: "run",
|
|
262
|
-
pipeline: "noop",
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
const [command, argv, options] = spawnState.spawn.mock.calls[0] ?? [];
|
|
266
|
-
expect(command).toBe(process.execPath);
|
|
267
|
-
expect(argv).toEqual([shimScriptPath, "run", "--mode", "tool", "noop"]);
|
|
268
|
-
expect(options).toMatchObject({ windowsHide: true });
|
|
269
|
-
expect(options).not.toHaveProperty("shell");
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
it("does not retry a failed Windows spawn with shell fallback", async () => {
|
|
273
|
-
setProcessPlatform("win32");
|
|
274
|
-
spawnState.spawn.mockReset();
|
|
275
|
-
spawnState.spawn.mockImplementationOnce(() => {
|
|
276
|
-
const child = new EventEmitter() as EventEmitter & {
|
|
277
|
-
stdout: PassThrough;
|
|
278
|
-
stderr: PassThrough;
|
|
279
|
-
kill: (signal?: string) => boolean;
|
|
280
|
-
};
|
|
281
|
-
child.stdout = new PassThrough();
|
|
282
|
-
child.stderr = new PassThrough();
|
|
283
|
-
child.kill = () => true;
|
|
284
|
-
const err = Object.assign(new Error("spawn failed"), { code: "ENOENT" });
|
|
285
|
-
setImmediate(() => child.emit("error", err));
|
|
286
|
-
return child;
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
const tool = createLobsterTool(fakeApi());
|
|
290
|
-
await expect(
|
|
291
|
-
tool.execute("call-win-no-retry", {
|
|
292
|
-
action: "run",
|
|
293
|
-
pipeline: "noop",
|
|
294
|
-
}),
|
|
295
|
-
).rejects.toThrow(/spawn failed/);
|
|
296
|
-
expect(spawnState.spawn).toHaveBeenCalledTimes(1);
|
|
297
|
-
});
|
|
298
|
-
|
|
299
339
|
it("can be gated off in sandboxed contexts", async () => {
|
|
300
340
|
const api = fakeApi();
|
|
301
341
|
const factoryTool = (ctx: OpenClawPluginToolContext) => {
|
|
302
342
|
if (ctx.sandboxed) {
|
|
303
343
|
return null;
|
|
304
344
|
}
|
|
305
|
-
return createLobsterTool(api
|
|
345
|
+
return createLobsterTool(api, {
|
|
346
|
+
runner: { run: vi.fn() },
|
|
347
|
+
});
|
|
306
348
|
};
|
|
307
349
|
|
|
308
350
|
expect(factoryTool(fakeCtx({ sandboxed: true }))).toBeNull();
|