Include full contents of all nested repositories

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 16:25:02 +01:00
parent 14ff8fd54c
commit 2401ed446f
7271 changed files with 1310112 additions and 6 deletions

View 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.

View 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

View 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 },
);
}

View File

@@ -0,0 +1,10 @@
{
"id": "lobster",
"name": "Lobster",
"description": "Typed workflow tool with resumable approvals.",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View 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"
]
}
}

View 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");
});
});

View 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,
};
},
};
}

View 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");
}

View 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/,
);
});
});

View 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 };
}