Include full contents of all nested repositories
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
75
openclaw/extensions/lobster/README.md
Normal file
75
openclaw/extensions/lobster/README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Lobster (plugin)
|
||||
|
||||
Adds the `lobster` agent tool as an **optional** plugin tool.
|
||||
|
||||
## What this is
|
||||
|
||||
- Lobster is a standalone workflow shell (typed JSON-first pipelines + approvals/resume).
|
||||
- This plugin integrates Lobster with OpenClaw _without core changes_.
|
||||
|
||||
## Enable
|
||||
|
||||
Because this tool can trigger side effects (via workflows), it is registered with `optional: true`.
|
||||
|
||||
Enable it in an agent allowlist:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"list": [
|
||||
{
|
||||
"id": "main",
|
||||
"tools": {
|
||||
"allow": [
|
||||
"lobster" // plugin id (enables all tools from this plugin)
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Using `openclaw.invoke` (Lobster → OpenClaw tools)
|
||||
|
||||
Some Lobster pipelines may include a `openclaw.invoke` step to call back into OpenClaw tools/plugins (for example: `gog` for Google Workspace, `gh` for GitHub, `message.send`, etc.).
|
||||
|
||||
For this to work, the OpenClaw Gateway must expose the tool bridge endpoint and the target tool must be allowed by policy:
|
||||
|
||||
- OpenClaw provides an HTTP endpoint: `POST /tools/invoke`.
|
||||
- The request is gated by **gateway auth** (e.g. `Authorization: Bearer …` when token auth is enabled).
|
||||
- The invoked tool is gated by **tool policy** (global + per-agent + provider + group policy). If the tool is not allowed, OpenClaw returns `404 Tool not available`.
|
||||
|
||||
### Allowlisting recommended
|
||||
|
||||
To avoid letting workflows call arbitrary tools, set a tight allowlist on the agent that will be used by `openclaw.invoke`.
|
||||
|
||||
Example (allow only a small set of tools):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"agents": {
|
||||
"list": [
|
||||
{
|
||||
"id": "main",
|
||||
"tools": {
|
||||
"allow": ["lobster", "web_fetch", "web_search", "gog", "gh"],
|
||||
"deny": ["gateway"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- If `tools.allow` is omitted or empty, it behaves like "allow everything (except denied)". For a real allowlist, set a **non-empty** `allow`.
|
||||
- Tool names depend on which plugins you have installed/enabled.
|
||||
|
||||
## Security
|
||||
|
||||
- Runs the `lobster` executable as a local subprocess.
|
||||
- Does not manage OAuth/tokens.
|
||||
- Uses timeouts, stdout caps, and strict JSON envelope parsing.
|
||||
- Ensure `lobster` is available on `PATH` for the gateway process.
|
||||
97
openclaw/extensions/lobster/SKILL.md
Normal file
97
openclaw/extensions/lobster/SKILL.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Lobster
|
||||
|
||||
Lobster executes multi-step workflows with approval checkpoints. Use it when:
|
||||
|
||||
- User wants a repeatable automation (triage, monitor, sync)
|
||||
- Actions need human approval before executing (send, post, delete)
|
||||
- Multiple tool calls should run as one deterministic operation
|
||||
|
||||
## When to use Lobster
|
||||
|
||||
| User intent | Use Lobster? |
|
||||
| ------------------------------------------------------ | --------------------------------------------- |
|
||||
| "Triage my email" | Yes — multi-step, may send replies |
|
||||
| "Send a message" | No — single action, use message tool directly |
|
||||
| "Check my email every morning and ask before replying" | Yes — scheduled workflow with approval |
|
||||
| "What's the weather?" | No — simple query |
|
||||
| "Monitor this PR and notify me of changes" | Yes — stateful, recurring |
|
||||
|
||||
## Basic usage
|
||||
|
||||
### Run a pipeline
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "run",
|
||||
"pipeline": "gog.gmail.search --query 'newer_than:1d' --max 20 | email.triage"
|
||||
}
|
||||
```
|
||||
|
||||
Returns structured result:
|
||||
|
||||
```json
|
||||
{
|
||||
"protocolVersion": 1,
|
||||
"ok": true,
|
||||
"status": "ok",
|
||||
"output": [{ "summary": {...}, "items": [...] }],
|
||||
"requiresApproval": null
|
||||
}
|
||||
```
|
||||
|
||||
### Handle approval
|
||||
|
||||
If the workflow needs approval:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "needs_approval",
|
||||
"output": [],
|
||||
"requiresApproval": {
|
||||
"prompt": "Send 3 draft replies?",
|
||||
"items": [...],
|
||||
"resumeToken": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Present the prompt to the user. If they approve:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "resume",
|
||||
"token": "<resumeToken>",
|
||||
"approve": true
|
||||
}
|
||||
```
|
||||
|
||||
## Example workflows
|
||||
|
||||
### Email triage
|
||||
|
||||
```
|
||||
gog.gmail.search --query 'newer_than:1d' --max 20 | email.triage
|
||||
```
|
||||
|
||||
Fetches recent emails, classifies into buckets (needs_reply, needs_action, fyi).
|
||||
|
||||
### Email triage with approval gate
|
||||
|
||||
```
|
||||
gog.gmail.search --query 'newer_than:1d' | email.triage | approve --prompt 'Process these?'
|
||||
```
|
||||
|
||||
Same as above, but halts for approval before returning.
|
||||
|
||||
## Key behaviors
|
||||
|
||||
- **Deterministic**: Same input → same output (no LLM variance in pipeline execution)
|
||||
- **Approval gates**: `approve` command halts execution, returns token
|
||||
- **Resumable**: Use `resume` action with token to continue
|
||||
- **Structured output**: Always returns JSON envelope with `protocolVersion`
|
||||
|
||||
## Don't use Lobster for
|
||||
|
||||
- Simple single-action requests (just use the tool directly)
|
||||
- Queries that need LLM interpretation mid-flow
|
||||
- One-off tasks that won't be repeated
|
||||
18
openclaw/extensions/lobster/index.ts
Normal file
18
openclaw/extensions/lobster/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type {
|
||||
AnyAgentTool,
|
||||
OpenClawPluginApi,
|
||||
OpenClawPluginToolFactory,
|
||||
} from "../../src/plugins/types.js";
|
||||
import { createLobsterTool } from "./src/lobster-tool.js";
|
||||
|
||||
export default function register(api: OpenClawPluginApi) {
|
||||
api.registerTool(
|
||||
((ctx) => {
|
||||
if (ctx.sandboxed) {
|
||||
return null;
|
||||
}
|
||||
return createLobsterTool(api) as AnyAgentTool;
|
||||
}) as OpenClawPluginToolFactory,
|
||||
{ optional: true },
|
||||
);
|
||||
}
|
||||
10
openclaw/extensions/lobster/openclaw.plugin.json
Normal file
10
openclaw/extensions/lobster/openclaw.plugin.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "lobster",
|
||||
"name": "Lobster",
|
||||
"description": "Typed workflow tool with resumable approvals.",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
11
openclaw/extensions/lobster/package.json
Normal file
11
openclaw/extensions/lobster/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@openclaw/lobster",
|
||||
"version": "2026.2.26",
|
||||
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
307
openclaw/extensions/lobster/src/lobster-tool.test.ts
Normal file
307
openclaw/extensions/lobster/src/lobster-tool.test.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../../../src/plugins/types.js";
|
||||
import {
|
||||
createWindowsCmdShimFixture,
|
||||
restorePlatformPathEnv,
|
||||
setProcessPlatform,
|
||||
snapshotPlatformPathEnv,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
const spawnState = vi.hoisted(() => ({
|
||||
queue: [] as Array<{ stdout: string; stderr?: string; exitCode?: number }>,
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
spawn: (...args: unknown[]) => spawnState.spawn(...args),
|
||||
}));
|
||||
|
||||
let createLobsterTool: typeof import("./lobster-tool.js").createLobsterTool;
|
||||
|
||||
function fakeApi(overrides: Partial<OpenClawPluginApi> = {}): OpenClawPluginApi {
|
||||
return {
|
||||
id: "lobster",
|
||||
name: "lobster",
|
||||
source: "test",
|
||||
config: {},
|
||||
pluginConfig: {},
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
runtime: { version: "test" } as any,
|
||||
logger: { info() {}, warn() {}, error() {}, debug() {} },
|
||||
registerTool() {},
|
||||
registerHttpHandler() {},
|
||||
registerChannel() {},
|
||||
registerGatewayMethod() {},
|
||||
registerCli() {},
|
||||
registerService() {},
|
||||
registerProvider() {},
|
||||
registerHook() {},
|
||||
registerHttpRoute() {},
|
||||
registerCommand() {},
|
||||
on() {},
|
||||
resolvePath: (p) => p,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeCtx(overrides: Partial<OpenClawPluginToolContext> = {}): OpenClawPluginToolContext {
|
||||
return {
|
||||
config: {},
|
||||
workspaceDir: "/tmp",
|
||||
agentDir: "/tmp",
|
||||
agentId: "main",
|
||||
sessionKey: "main",
|
||||
messageChannel: undefined,
|
||||
agentAccountId: undefined,
|
||||
sandboxed: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("lobster plugin tool", () => {
|
||||
let tempDir = "";
|
||||
const originalProcessState = snapshotPlatformPathEnv();
|
||||
|
||||
beforeAll(async () => {
|
||||
({ createLobsterTool } = await import("./lobster-tool.js"));
|
||||
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-plugin-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restorePlatformPathEnv(originalProcessState);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!tempDir) {
|
||||
return;
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
await fs.rm(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 });
|
||||
} else {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
spawnState.queue.length = 0;
|
||||
spawnState.spawn.mockReset();
|
||||
spawnState.spawn.mockImplementation(() => {
|
||||
const next = spawnState.queue.shift() ?? { stdout: "" };
|
||||
const stdout = new PassThrough();
|
||||
const stderr = new PassThrough();
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: PassThrough;
|
||||
stderr: PassThrough;
|
||||
kill: (signal?: string) => boolean;
|
||||
};
|
||||
child.stdout = stdout;
|
||||
child.stderr = stderr;
|
||||
child.kill = () => true;
|
||||
|
||||
setImmediate(() => {
|
||||
if (next.stderr) {
|
||||
stderr.end(next.stderr);
|
||||
} else {
|
||||
stderr.end();
|
||||
}
|
||||
stdout.end(next.stdout);
|
||||
child.emit("exit", next.exitCode ?? 0);
|
||||
});
|
||||
|
||||
return child;
|
||||
});
|
||||
});
|
||||
|
||||
const queueSuccessfulEnvelope = (hello = "world") => {
|
||||
spawnState.queue.push({
|
||||
stdout: JSON.stringify({
|
||||
ok: true,
|
||||
status: "ok",
|
||||
output: [{ hello }],
|
||||
requiresApproval: null,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
it("runs lobster and returns parsed envelope in details", async () => {
|
||||
spawnState.queue.push({
|
||||
stdout: JSON.stringify({
|
||||
ok: true,
|
||||
status: "ok",
|
||||
output: [{ hello: "world" }],
|
||||
requiresApproval: null,
|
||||
}),
|
||||
});
|
||||
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
const res = await tool.execute("call1", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
expect(spawnState.spawn).toHaveBeenCalled();
|
||||
expect(res.details).toMatchObject({ ok: true, status: "ok" });
|
||||
});
|
||||
|
||||
it("tolerates noisy stdout before the JSON envelope", async () => {
|
||||
const payload = { ok: true, status: "ok", output: [], requiresApproval: null };
|
||||
spawnState.queue.push({
|
||||
stdout: `noise before json\n${JSON.stringify(payload)}`,
|
||||
});
|
||||
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
const res = await tool.execute("call-noisy", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
expect(res.details).toMatchObject({ ok: true, status: "ok" });
|
||||
});
|
||||
|
||||
it("requires action", async () => {
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
await expect(tool.execute("call-action-missing", {})).rejects.toThrow(/action required/);
|
||||
});
|
||||
|
||||
it("requires pipeline for run action", async () => {
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
await expect(
|
||||
tool.execute("call-pipeline-missing", {
|
||||
action: "run",
|
||||
}),
|
||||
).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 () => {
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
await expect(
|
||||
tool.execute("call2c", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
cwd: "/tmp",
|
||||
}),
|
||||
).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("rejects invalid JSON from lobster", async () => {
|
||||
spawnState.queue.push({ stdout: "nope" });
|
||||
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
await expect(
|
||||
tool.execute("call3", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
}),
|
||||
).rejects.toThrow(/invalid JSON/);
|
||||
});
|
||||
|
||||
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-bin", "lobster.cmd");
|
||||
await createWindowsCmdShimFixture({
|
||||
shimPath,
|
||||
scriptPath: shimScriptPath,
|
||||
shimLine: `"%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());
|
||||
await tool.execute("call-win-shim", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
});
|
||||
|
||||
const [command, argv, options] = spawnState.spawn.mock.calls[0] ?? [];
|
||||
expect(command).toBe(process.execPath);
|
||||
expect(argv).toEqual([shimScriptPath, "run", "--mode", "tool", "noop"]);
|
||||
expect(options).toMatchObject({ windowsHide: true });
|
||||
expect(options).not.toHaveProperty("shell");
|
||||
});
|
||||
|
||||
it("does not retry a failed Windows spawn with shell fallback", async () => {
|
||||
setProcessPlatform("win32");
|
||||
spawnState.spawn.mockReset();
|
||||
spawnState.spawn.mockImplementationOnce(() => {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: PassThrough;
|
||||
stderr: PassThrough;
|
||||
kill: (signal?: string) => boolean;
|
||||
};
|
||||
child.stdout = new PassThrough();
|
||||
child.stderr = new PassThrough();
|
||||
child.kill = () => true;
|
||||
const err = Object.assign(new Error("spawn failed"), { code: "ENOENT" });
|
||||
setImmediate(() => child.emit("error", err));
|
||||
return child;
|
||||
});
|
||||
|
||||
const tool = createLobsterTool(fakeApi());
|
||||
await expect(
|
||||
tool.execute("call-win-no-retry", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
}),
|
||||
).rejects.toThrow(/spawn failed/);
|
||||
expect(spawnState.spawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("can be gated off in sandboxed contexts", async () => {
|
||||
const api = fakeApi();
|
||||
const factoryTool = (ctx: OpenClawPluginToolContext) => {
|
||||
if (ctx.sandboxed) {
|
||||
return null;
|
||||
}
|
||||
return createLobsterTool(api);
|
||||
};
|
||||
|
||||
expect(factoryTool(fakeCtx({ sandboxed: true }))).toBeNull();
|
||||
expect(factoryTool(fakeCtx({ sandboxed: false }))?.name).toBe("lobster");
|
||||
});
|
||||
});
|
||||
266
openclaw/extensions/lobster/src/lobster-tool.ts
Normal file
266
openclaw/extensions/lobster/src/lobster-tool.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { OpenClawPluginApi } from "../../../src/plugins/types.js";
|
||||
import { resolveWindowsLobsterSpawn } from "./windows-spawn.js";
|
||||
|
||||
type LobsterEnvelope =
|
||||
| {
|
||||
ok: true;
|
||||
status: "ok" | "needs_approval" | "cancelled";
|
||||
output: unknown[];
|
||||
requiresApproval: null | {
|
||||
type: "approval_request";
|
||||
prompt: string;
|
||||
items: unknown[];
|
||||
resumeToken?: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
error: { type?: string; message: string };
|
||||
};
|
||||
|
||||
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");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async function runLobsterSubprocessOnce(params: {
|
||||
execPath: string;
|
||||
argv: string[];
|
||||
cwd: string;
|
||||
timeoutMs: number;
|
||||
maxStdoutBytes: number;
|
||||
}) {
|
||||
const { execPath, argv, cwd } = params;
|
||||
const timeoutMs = Math.max(200, params.timeoutMs);
|
||||
const maxStdoutBytes = Math.max(1024, params.maxStdoutBytes);
|
||||
|
||||
const env = { ...process.env, LOBSTER_MODE: "tool" } as Record<string, string | undefined>;
|
||||
const nodeOptions = env.NODE_OPTIONS ?? "";
|
||||
if (nodeOptions.includes("--inspect")) {
|
||||
delete env.NODE_OPTIONS;
|
||||
}
|
||||
const spawnTarget =
|
||||
process.platform === "win32"
|
||||
? resolveWindowsLobsterSpawn(execPath, argv, env)
|
||||
: { command: execPath, argv };
|
||||
|
||||
return await new Promise<{ stdout: string }>((resolve, reject) => {
|
||||
const child = spawn(spawnTarget.command, spawnTarget.argv, {
|
||||
cwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
windowsHide: spawnTarget.windowsHide,
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stdoutBytes = 0;
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
|
||||
const settle = (
|
||||
result: { ok: true; value: { stdout: string } } | { ok: false; error: Error },
|
||||
) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (result.ok) {
|
||||
resolve(result.value);
|
||||
} else {
|
||||
reject(result.error);
|
||||
}
|
||||
};
|
||||
|
||||
const failAndTerminate = (message: string) => {
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} finally {
|
||||
settle({ ok: false, error: new Error(message) });
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout?.setEncoding("utf8");
|
||||
child.stderr?.setEncoding("utf8");
|
||||
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
const str = String(chunk);
|
||||
stdoutBytes += Buffer.byteLength(str, "utf8");
|
||||
if (stdoutBytes > maxStdoutBytes) {
|
||||
failAndTerminate("lobster output exceeded maxStdoutBytes");
|
||||
return;
|
||||
}
|
||||
stdout += str;
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
failAndTerminate("lobster subprocess timed out");
|
||||
}, timeoutMs);
|
||||
|
||||
child.once("error", (err) => {
|
||||
settle({ ok: false, error: err });
|
||||
});
|
||||
|
||||
child.once("exit", (code) => {
|
||||
if (code !== 0) {
|
||||
settle({
|
||||
ok: false,
|
||||
error: new Error(`lobster failed (${code ?? "?"}): ${stderr.trim() || stdout.trim()}`),
|
||||
});
|
||||
return;
|
||||
}
|
||||
settle({ ok: true, value: { stdout } });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseEnvelope(stdout: string): LobsterEnvelope {
|
||||
const trimmed = stdout.trim();
|
||||
|
||||
const tryParse = (input: string) => {
|
||||
try {
|
||||
return JSON.parse(input) as unknown;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
let parsed: unknown = tryParse(trimmed);
|
||||
|
||||
// Some environments can leak extra stdout (e.g. warnings/logs) before the
|
||||
// final JSON envelope. Be tolerant and parse the last JSON-looking suffix.
|
||||
if (parsed === undefined) {
|
||||
const suffixMatch = trimmed.match(/({[\s\S]*}|\[[\s\S]*])\s*$/);
|
||||
if (suffixMatch?.[1]) {
|
||||
parsed = tryParse(suffixMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed === undefined) {
|
||||
throw new Error("lobster returned invalid JSON");
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
throw new Error("lobster returned invalid JSON envelope");
|
||||
}
|
||||
|
||||
const ok = (parsed as { ok?: unknown }).ok;
|
||||
if (ok === true || ok === false) {
|
||||
return parsed as 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",
|
||||
label: "Lobster Workflow",
|
||||
description:
|
||||
"Run Lobster pipelines as a local-first workflow runtime (typed JSON envelope + resumable approvals).",
|
||||
parameters: Type.Object({
|
||||
// NOTE: Prefer string enums in tool schemas; some providers reject unions/anyOf.
|
||||
action: Type.Unsafe<"run" | "resume">({ type: "string", enum: ["run", "resume"] }),
|
||||
pipeline: Type.Optional(Type.String()),
|
||||
argsJson: Type.Optional(Type.String()),
|
||||
token: Type.Optional(Type.String()),
|
||||
approve: Type.Optional(Type.Boolean()),
|
||||
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()),
|
||||
}),
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const action = typeof params.action === "string" ? params.action.trim() : "";
|
||||
if (!action) {
|
||||
throw new Error("action required");
|
||||
}
|
||||
|
||||
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 = buildLobsterArgv(action, params);
|
||||
|
||||
if (api.runtime?.version && api.logger?.debug) {
|
||||
api.logger.debug(`lobster plugin runtime=${api.runtime.version}`);
|
||||
}
|
||||
|
||||
const { stdout } = await runLobsterSubprocessOnce({
|
||||
execPath,
|
||||
argv,
|
||||
cwd,
|
||||
timeoutMs,
|
||||
maxStdoutBytes,
|
||||
});
|
||||
|
||||
const envelope = parseEnvelope(stdout);
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }],
|
||||
details: envelope,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
56
openclaw/extensions/lobster/src/test-helpers.ts
Normal file
56
openclaw/extensions/lobster/src/test-helpers.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
type PathEnvKey = "PATH" | "Path" | "PATHEXT" | "Pathext";
|
||||
|
||||
const PATH_ENV_KEYS = ["PATH", "Path", "PATHEXT", "Pathext"] as const;
|
||||
|
||||
export type PlatformPathEnvSnapshot = {
|
||||
platformDescriptor: PropertyDescriptor | undefined;
|
||||
env: Record<PathEnvKey, string | undefined>;
|
||||
};
|
||||
|
||||
export function setProcessPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: platform,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function snapshotPlatformPathEnv(): PlatformPathEnvSnapshot {
|
||||
return {
|
||||
platformDescriptor: Object.getOwnPropertyDescriptor(process, "platform"),
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
Path: process.env.Path,
|
||||
PATHEXT: process.env.PATHEXT,
|
||||
Pathext: process.env.Pathext,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function restorePlatformPathEnv(snapshot: PlatformPathEnvSnapshot): void {
|
||||
if (snapshot.platformDescriptor) {
|
||||
Object.defineProperty(process, "platform", snapshot.platformDescriptor);
|
||||
}
|
||||
|
||||
for (const key of PATH_ENV_KEYS) {
|
||||
const value = snapshot.env[key];
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
continue;
|
||||
}
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWindowsCmdShimFixture(params: {
|
||||
shimPath: string;
|
||||
scriptPath: string;
|
||||
shimLine: string;
|
||||
}): Promise<void> {
|
||||
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.shimLine}\r\n`, "utf8");
|
||||
}
|
||||
115
openclaw/extensions/lobster/src/windows-spawn.test.ts
Normal file
115
openclaw/extensions/lobster/src/windows-spawn.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
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 {
|
||||
createWindowsCmdShimFixture,
|
||||
restorePlatformPathEnv,
|
||||
setProcessPlatform,
|
||||
snapshotPlatformPathEnv,
|
||||
} from "./test-helpers.js";
|
||||
import { resolveWindowsLobsterSpawn } from "./windows-spawn.js";
|
||||
|
||||
describe("resolveWindowsLobsterSpawn", () => {
|
||||
let tempDir = "";
|
||||
const originalProcessState = snapshotPlatformPathEnv();
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-win-spawn-"));
|
||||
setProcessPlatform("win32");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
restorePlatformPathEnv(originalProcessState);
|
||||
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 createWindowsCmdShimFixture({
|
||||
shimPath,
|
||||
scriptPath,
|
||||
shimLine: `"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`,
|
||||
});
|
||||
|
||||
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 createWindowsCmdShimFixture({
|
||||
shimPath,
|
||||
scriptPath,
|
||||
shimLine: `"%~dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`,
|
||||
});
|
||||
|
||||
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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
193
openclaw/extensions/lobster/src/windows-spawn.ts
Normal file
193
openclaw/extensions/lobster/src/windows-spawn.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
type SpawnTarget = {
|
||||
command: string;
|
||||
argv: string[];
|
||||
windowsHide?: boolean;
|
||||
};
|
||||
|
||||
function isFilePath(value: string): boolean {
|
||||
try {
|
||||
const stat = fs.statSync(value);
|
||||
return stat.isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWindowsExecutablePath(execPath: string, env: NodeJS.ProcessEnv): string {
|
||||
if (execPath.includes("/") || execPath.includes("\\") || path.isAbsolute(execPath)) {
|
||||
return execPath;
|
||||
}
|
||||
|
||||
const pathValue = env.PATH ?? env.Path ?? process.env.PATH ?? process.env.Path ?? "";
|
||||
const pathEntries = pathValue
|
||||
.split(";")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const hasExtension = path.extname(execPath).length > 0;
|
||||
const pathExtRaw =
|
||||
env.PATHEXT ??
|
||||
env.Pathext ??
|
||||
process.env.PATHEXT ??
|
||||
process.env.Pathext ??
|
||||
".EXE;.CMD;.BAT;.COM";
|
||||
const pathExt = hasExtension
|
||||
? [""]
|
||||
: pathExtRaw
|
||||
.split(";")
|
||||
.map((ext) => ext.trim())
|
||||
.filter(Boolean)
|
||||
.map((ext) => (ext.startsWith(".") ? ext : `.${ext}`));
|
||||
|
||||
for (const dir of pathEntries) {
|
||||
for (const ext of pathExt) {
|
||||
for (const candidateExt of [ext, ext.toLowerCase(), ext.toUpperCase()]) {
|
||||
const candidate = path.join(dir, `${execPath}${candidateExt}`);
|
||||
if (isFilePath(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return execPath;
|
||||
}
|
||||
|
||||
function resolveBinEntry(binField: string | Record<string, string> | undefined): string | null {
|
||||
if (typeof binField === "string") {
|
||||
const trimmed = binField.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
if (!binField || typeof binField !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const preferred = binField.lobster;
|
||||
if (typeof preferred === "string" && preferred.trim()) {
|
||||
return preferred.trim();
|
||||
}
|
||||
|
||||
for (const value of Object.values(binField)) {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveLobsterScriptFromPackageJson(wrapperPath: string): string | null {
|
||||
const wrapperDir = path.dirname(wrapperPath);
|
||||
const packageDirs = [
|
||||
// Local install: <repo>/node_modules/.bin/lobster.cmd -> ../lobster
|
||||
path.resolve(wrapperDir, "..", "lobster"),
|
||||
// Global npm install: <npm-prefix>/lobster.cmd -> ./node_modules/lobster
|
||||
path.resolve(wrapperDir, "node_modules", "lobster"),
|
||||
];
|
||||
|
||||
for (const packageDir of packageDirs) {
|
||||
const packageJsonPath = path.join(packageDir, "package.json");
|
||||
if (!isFilePath(packageJsonPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
|
||||
bin?: string | Record<string, string>;
|
||||
};
|
||||
const scriptRel = resolveBinEntry(packageJson.bin);
|
||||
if (!scriptRel) {
|
||||
continue;
|
||||
}
|
||||
const scriptPath = path.resolve(packageDir, scriptRel);
|
||||
if (isFilePath(scriptPath)) {
|
||||
return scriptPath;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed package metadata; caller will throw a guided error.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveLobsterScriptFromCmdShim(wrapperPath: string): string | null {
|
||||
if (!isFilePath(wrapperPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(wrapperPath, "utf8");
|
||||
const candidates: string[] = [];
|
||||
const extractRelativeFromToken = (token: string): string | null => {
|
||||
const match = token.match(/%~?dp0%\s*[\\/]*(.*)$/i);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const relative = match[1];
|
||||
if (!relative) {
|
||||
return null;
|
||||
}
|
||||
return relative;
|
||||
};
|
||||
|
||||
const matches = content.matchAll(/"([^"\r\n]*)"/g);
|
||||
for (const match of matches) {
|
||||
const token = match[1] ?? "";
|
||||
const relative = extractRelativeFromToken(token);
|
||||
if (!relative) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedRelative = relative
|
||||
.trim()
|
||||
.replace(/[\\/]+/g, path.sep)
|
||||
.replace(/^[\\/]+/, "");
|
||||
const candidate = path.resolve(path.dirname(wrapperPath), normalizedRelative);
|
||||
if (isFilePath(candidate)) {
|
||||
candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
const nonNode = candidates.find((candidate) => {
|
||||
const base = path.basename(candidate).toLowerCase();
|
||||
return base !== "node.exe" && base !== "node";
|
||||
});
|
||||
if (nonNode) {
|
||||
return nonNode;
|
||||
}
|
||||
} catch {
|
||||
// Ignore unreadable shims; caller will throw a guided error.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveWindowsLobsterSpawn(
|
||||
execPath: string,
|
||||
argv: string[],
|
||||
env: NodeJS.ProcessEnv,
|
||||
): SpawnTarget {
|
||||
const resolvedExecPath = resolveWindowsExecutablePath(execPath, env);
|
||||
const ext = path.extname(resolvedExecPath).toLowerCase();
|
||||
if (ext !== ".cmd" && ext !== ".bat") {
|
||||
return { command: resolvedExecPath, argv };
|
||||
}
|
||||
|
||||
const scriptPath =
|
||||
resolveLobsterScriptFromCmdShim(resolvedExecPath) ??
|
||||
resolveLobsterScriptFromPackageJson(resolvedExecPath);
|
||||
if (!scriptPath) {
|
||||
throw new Error(
|
||||
`${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).`,
|
||||
);
|
||||
}
|
||||
|
||||
const entryExt = path.extname(scriptPath).toLowerCase();
|
||||
if (entryExt === ".exe") {
|
||||
return { command: scriptPath, argv, windowsHide: true };
|
||||
}
|
||||
return { command: process.execPath, argv: [scriptPath, ...argv], windowsHide: true };
|
||||
}
|
||||
Reference in New Issue
Block a user