Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,93 @@
# Lobster (plugin)
Adds the `lobster` agent tool as an **optional** plugin tool.
## Install
```bash
openclaw plugins install @openclaw/lobster
```
Restart the Gateway after installing or updating the plugin.
## 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 Lobster in process via the published `@clawdbot/lobster/core` runtime.
- Does not manage OAuth/tokens.
- Uses timeouts, stdout caps, and strict JSON envelope parsing.
## Docs
- https://docs.openclaw.ai/tools/lobster
## Package
- Plugin id: `lobster`
- Tool: `lobster`
- Package: `@openclaw/lobster`
- Minimum OpenClaw host: `2026.4.25`

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,25 @@
// Lobster plugin entrypoint registers its OpenClaw integration.
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import type { AnyAgentTool, OpenClawPluginApi, OpenClawPluginToolFactory } from "./runtime-api.js";
import { createLobsterTool } from "./src/lobster-tool.js";
export default definePluginEntry({
id: "lobster",
name: "Lobster",
description: "Optional local shell helper tools",
register(api: OpenClawPluginApi) {
api.registerTool(
((ctx) => {
if (ctx.sandboxed) {
return null;
}
const taskFlow =
api.runtime?.tasks.managedFlows && ctx.sessionKey
? api.runtime.tasks.managedFlows.fromToolContext(ctx)
: undefined;
return createLobsterTool(api, { taskFlow }) as AnyAgentTool;
}) as OpenClawPluginToolFactory,
{ optional: true },
);
},
});

108
extensions/lobster/npm-shrinkwrap.json generated Normal file
View File

@@ -0,0 +1,108 @@
{
"name": "@openclaw/lobster",
"version": "2026.6.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/lobster",
"version": "2026.6.11",
"dependencies": {
"@clawdbot/lobster": "2026.6.11",
"typebox": "1.3.3"
}
},
"node_modules/@clawdbot/lobster": {
"version": "2026.6.11",
"resolved": "https://registry.npmjs.org/@clawdbot/lobster/-/lobster-2026.6.11.tgz",
"integrity": "sha512-zeNyXvDqYajDHxFlMy7wn6TfBUsMOIJgiw0lDn+ltGiQUH/F3w+8SWHCOyMNmzoZfXSQw9uJK44SVCzEkxXsUw==",
"license": "MIT",
"dependencies": {
"ajv": "^8.20.0",
"yaml": "^2.9.0"
},
"bin": {
"clawd.invoke": "bin/clawd.invoke.js",
"lobster": "bin/lobster.js",
"openclaw.invoke": "bin/openclaw.invoke.js"
},
"engines": {
"node": ">=22"
}
},
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/typebox": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.3.tgz",
"integrity": "sha512-URXGUE31PJDQC+PtRMJeLdF4kmmOdFoVPikPCtV2oOIhUpNpppEdIz7W8bH8cFYPYHdDpaRvqwdegMTmHliudg==",
"license": "MIT"
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
}
}
}

View File

@@ -0,0 +1,21 @@
{
"id": "lobster",
"activation": {
"onStartup": true
},
"name": "Lobster",
"description": "Lobster workflow tool plugin for typed pipelines and resumable approvals.",
"contracts": {
"tools": ["lobster"]
},
"toolMetadata": {
"lobster": {
"optional": true
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,37 @@
{
"name": "@openclaw/lobster",
"version": "2026.6.11",
"description": "Lobster workflow tool plugin for typed pipelines and resumable approvals.",
"repository": {
"type": "git",
"url": "https://github.com/openclaw/openclaw"
},
"type": "module",
"dependencies": {
"@clawdbot/lobster": "2026.6.11",
"typebox": "1.3.3"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
],
"install": {
"npmSpec": "@openclaw/lobster",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.25"
},
"compat": {
"pluginApi": ">=2026.6.11"
},
"build": {
"openclawVersion": "2026.6.11"
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}
}

View File

@@ -0,0 +1,13 @@
// Lobster API module exposes the plugin public contract.
export { definePluginEntry } from "openclaw/plugin-sdk/core";
export type {
AnyAgentTool,
OpenClawPluginApi,
OpenClawPluginToolContext,
OpenClawPluginToolFactory,
} from "openclaw/plugin-sdk/core";
export {
applyWindowsSpawnProgramPolicy,
materializeWindowsSpawnProgram,
resolveWindowsSpawnProgramCandidate,
} from "openclaw/plugin-sdk/windows-spawn";

View File

@@ -0,0 +1,153 @@
// Lobster plugin module implements lobster ajv cache behavior.
import { createHash } from "node:crypto";
import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";
const installedSymbol = Symbol.for("openclaw.lobster.ajv-compile-cache.installed");
const cacheSymbol = Symbol.for("openclaw.lobster.ajv-compile-cache.entries");
const maxEntries = 512;
type ValidateFunction = (value: unknown) => boolean;
type AjvInstance = {
compile: (schema: unknown) => ValidateFunction;
removeSchema: (schemaKeyRef?: unknown) => AjvInstance;
};
type AjvConstructor = {
new (opts?: object): AjvInstance;
prototype: AjvInstance;
};
type AjvWithCompileCache = AjvInstance & {
[cacheSymbol]?: Map<string, CompileCacheEntry>;
};
type AjvPrototypePatch = AjvInstance & {
[installedSymbol]?: boolean;
};
type CompileCacheEntry = {
schema: unknown;
validate: ValidateFunction;
};
function stableJsonStringify(value: unknown, seen = new WeakSet<object>()): string {
if (value === null || typeof value !== "object") {
return JSON.stringify(value);
}
if (seen.has(value)) {
throw new TypeError("Cannot cache cyclic JSON schema");
}
seen.add(value);
if (Array.isArray(value)) {
const items = value.map((entry) => stableJsonStringify(entry, seen));
seen.delete(value);
return `[${items.join(",")}]`;
}
const record = value as Record<string, unknown>;
const keys = Object.keys(record).toSorted();
const properties = keys
.filter((key) => record[key] !== undefined)
.map((key) => `${JSON.stringify(key)}:${stableJsonStringify(record[key], seen)}`);
seen.delete(value);
return `{${properties.join(",")}}`;
}
function compileCacheKey(schema: unknown): string | null {
try {
return createHash("sha256").update(stableJsonStringify(schema)).digest("hex");
} catch {
return null;
}
}
function readCompileCache(instance: AjvWithCompileCache): Map<string, CompileCacheEntry> {
let cache = instance[cacheSymbol];
if (!cache) {
cache = new Map<string, CompileCacheEntry>();
Object.defineProperty(instance, cacheSymbol, {
value: cache,
configurable: true,
});
}
return cache;
}
function rememberCompiledValidator(params: {
cache: Map<string, CompileCacheEntry>;
instance: AjvWithCompileCache;
key: string;
removeSchema: AjvInstance["removeSchema"];
schema: unknown;
validate: ValidateFunction;
}) {
const { cache, instance, key, removeSchema, schema, validate } = params;
if (!cache.has(key) && cache.size >= maxEntries) {
const oldest = cache.keys().next().value;
if (oldest !== undefined) {
const evicted = cache.get(oldest);
cache.delete(oldest);
if (evicted) {
removeSchema.call(instance, evicted.schema);
}
}
}
cache.set(key, { schema, validate });
}
async function resolveLobsterAjvConstructor(packageEntryPath: string): Promise<AjvConstructor> {
const lobsterRequire = createRequire(packageEntryPath);
const ajvPath = lobsterRequire.resolve("ajv");
const ajvModule = (await import(pathToFileURL(ajvPath).href)) as { default?: unknown };
return (ajvModule.default ?? ajvModule) as AjvConstructor;
}
export async function installLobsterAjvCompileCache(packageEntryPath: string) {
let AjvCtor: AjvConstructor;
try {
AjvCtor = await resolveLobsterAjvConstructor(packageEntryPath);
} catch {
return;
}
const proto = AjvCtor.prototype as AjvPrototypePatch;
if (proto[installedSymbol]) {
return;
}
const originalCompile = proto.compile;
const originalRemoveSchema = proto.removeSchema;
Object.defineProperty(proto, installedSymbol, {
value: true,
configurable: true,
});
proto.compile = function compileWithContentCache(
this: AjvWithCompileCache,
schema: unknown,
): ValidateFunction {
const key = compileCacheKey(schema);
if (!key) {
return originalCompile.call(this, schema);
}
const cache = readCompileCache(this);
const cached = cache.get(key);
if (cached) {
return cached.validate;
}
const validate = originalCompile.call(this, schema);
rememberCompiledValidator({
cache,
instance: this,
key,
removeSchema: originalRemoveSchema,
schema,
validate,
});
return validate;
};
proto.removeSchema = function removeSchemaAndClearContentCache(
this: AjvWithCompileCache,
schemaKeyRef?: unknown,
) {
this[cacheSymbol]?.clear();
return originalRemoveSchema.call(this, schemaKeyRef);
};
}

View File

@@ -0,0 +1,61 @@
// Lobster type declarations define plugin contracts.
declare module "@clawdbot/lobster/core" {
type LobsterApprovalRequest = {
type: "approval_request";
prompt: string;
items: unknown[];
resumeToken?: string;
approvalId?: string;
} | null;
type LobsterToolContext = {
cwd?: string;
env?: Record<string, string | undefined>;
stdin?: NodeJS.ReadableStream;
stdout?: NodeJS.WritableStream;
stderr?: NodeJS.WritableStream;
signal?: AbortSignal;
registry?: unknown;
llmAdapters?: Record<string, unknown>;
};
type LobsterToolEnvelope =
| {
protocolVersion: 1;
ok: true;
status: "ok" | "needs_approval" | "needs_input" | "cancelled";
output: unknown[];
requiresApproval: LobsterApprovalRequest;
requiresInput?: {
prompt: string;
schema?: unknown;
items?: unknown[];
resumeToken?: string;
approvalId?: string;
} | null;
}
| {
protocolVersion: 1;
ok: false;
error: {
type: string;
message: string;
};
};
export function runToolRequest(params: {
pipeline?: string;
filePath?: string;
args?: Record<string, unknown>;
ctx?: LobsterToolContext;
}): Promise<LobsterToolEnvelope>;
export function resumeToolRequest(params: {
token?: string;
approvalId?: string;
approved?: boolean;
response?: unknown;
cancel?: boolean;
ctx?: LobsterToolContext;
}): Promise<LobsterToolEnvelope>;
}

View File

@@ -0,0 +1,741 @@
// Lobster tests cover lobster runner plugin behavior.
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createEmbeddedLobsterRunner,
loadEmbeddedToolRuntimeFromPackage,
resolveLobsterCwd,
} from "./lobster-runner.js";
const requireForTest = createRequire(import.meta.url);
const ajvInternalCacheKey = "_cache";
type AjvInstance = {
compile: (schema: unknown) => unknown;
};
type AjvConstructor = new (opts?: object) => AjvInstance;
function readAjvInternalCacheSize(ajv: unknown): number {
return (ajv as Record<string, { size: number } | undefined>)[ajvInternalCacheKey]?.size ?? 0;
}
async function importLobsterAjvConstructor(): Promise<AjvConstructor> {
const lobsterEntry = requireForTest.resolve("@clawdbot/lobster");
const lobsterRequire = createRequire(lobsterEntry);
const ajvPath = lobsterRequire.resolve("ajv");
const ajvModule = (await import(pathToFileURL(ajvPath).href)) as { default?: unknown };
return ajvModule.default as AjvConstructor;
}
function createRepeatedResponseSchema() {
return {
type: "object",
properties: {
ok: { type: "boolean" },
output: {
type: "array",
items: { type: "object" },
},
},
};
}
function createUniqueResponseSchema(index: number) {
return {
...createRepeatedResponseSchema(),
properties: {
...createRepeatedResponseSchema().properties,
[`unique_${index}`]: { type: "string" },
},
};
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`expected ${label} to be a record`);
}
return value as Record<string, unknown>;
}
function requireFirstCallParam(calls: ReadonlyArray<readonly unknown[]>, label: string) {
const call = calls[0];
if (!call) {
throw new Error(`expected ${label} call`);
}
return call[0];
}
function expectToolContext(value: unknown, expected: { cwd?: string; mode: "tool" }) {
const ctx = requireRecord(value, "tool context");
if (expected.cwd !== undefined) {
expect(ctx.cwd).toBe(expected.cwd);
}
expect(ctx.mode).toBe(expected.mode);
expect(ctx.signal).toBeInstanceOf(AbortSignal);
}
describe("resolveLobsterCwd", () => {
it("defaults to the current working directory", () => {
expect(resolveLobsterCwd(undefined)).toBe(process.cwd());
});
it("keeps relative paths inside the repo root", () => {
expect(resolveLobsterCwd("extensions/lobster")).toBe(
path.resolve(process.cwd(), "extensions/lobster"),
);
});
});
describe("createEmbeddedLobsterRunner", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("runs inline pipelines through the embedded runtime", async () => {
const runtime = {
runToolRequest: vi.fn().mockResolvedValue({
ok: true,
protocolVersion: 1,
status: "ok",
output: [{ hello: "world" }],
requiresApproval: null,
}),
resumeToolRequest: vi.fn(),
};
const runner = createEmbeddedLobsterRunner({
loadRuntime: vi.fn().mockResolvedValue(runtime),
});
const envelope = await runner.run({
action: "run",
pipeline: "exec --json=true echo hi",
cwd: process.cwd(),
timeoutMs: 2000,
maxStdoutBytes: 4096,
});
expect(runtime.runToolRequest).toHaveBeenCalledTimes(1);
const request = requireRecord(
requireFirstCallParam(runtime.runToolRequest.mock.calls, "run tool request"),
"run tool request",
);
expect(request.pipeline).toBe("exec --json=true echo hi");
expectToolContext(request.ctx, { cwd: process.cwd(), mode: "tool" });
expect(envelope).toEqual({
ok: true,
status: "ok",
output: [{ hello: "world" }],
requiresApproval: null,
});
});
it.each([
"exec --json=true cat data.json",
"exec --json=true cat config.yaml",
"exec --json=true cat flow.lobster",
"exec --json=true cat /tmp/missing.json",
"http.fetch https://example.test/workflows/flow.lobster",
"exec --json=true echo nested/path",
])("keeps inline pipeline with file-like args as a pipeline: %s", async (pipeline) => {
const runtime = {
runToolRequest: vi.fn().mockResolvedValue({
ok: true,
protocolVersion: 1,
status: "ok",
output: [],
requiresApproval: null,
}),
resumeToolRequest: vi.fn(),
};
const runner = createEmbeddedLobsterRunner({
loadRuntime: vi.fn().mockResolvedValue(runtime),
});
await runner.run({
action: "run",
pipeline,
cwd: process.cwd(),
timeoutMs: 2000,
maxStdoutBytes: 4096,
});
expect(runtime.runToolRequest).toHaveBeenCalledOnce();
const request = requireRecord(
requireFirstCallParam(runtime.runToolRequest.mock.calls, "inline run tool request"),
"inline run tool request",
);
expect(request.pipeline).toBe(pipeline);
expect(request.filePath).toBeUndefined();
});
it("detects workflow files and parses argsJson", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-runner-"));
const workflowPath = path.join(tempDir, "workflow.lobster");
await fs.writeFile(workflowPath, "steps: []\n", "utf8");
try {
const runtime = {
runToolRequest: vi.fn().mockResolvedValue({
ok: true,
protocolVersion: 1,
status: "ok",
output: [],
requiresApproval: null,
}),
resumeToolRequest: vi.fn(),
};
const runner = createEmbeddedLobsterRunner({
loadRuntime: vi.fn().mockResolvedValue(runtime),
});
await runner.run({
action: "run",
pipeline: "workflow.lobster",
argsJson: '{"limit":3}',
cwd: tempDir,
timeoutMs: 2000,
maxStdoutBytes: 4096,
});
expect(runtime.runToolRequest).toHaveBeenCalledOnce();
const request = requireRecord(
requireFirstCallParam(runtime.runToolRequest.mock.calls, "workflow run tool request"),
"workflow run tool request",
);
expect(request.filePath).toBe(workflowPath);
expect(request.args).toEqual({ limit: 3 });
expectToolContext(request.ctx, { cwd: tempDir, mode: "tool" });
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("detects existing workflow file paths that contain spaces", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-runner-"));
const workflowPath = path.join(tempDir, "daily inbox.lobster");
await fs.writeFile(workflowPath, "steps: []\n", "utf8");
try {
const runtime = {
runToolRequest: vi.fn().mockResolvedValue({
ok: true,
protocolVersion: 1,
status: "ok",
output: [],
requiresApproval: null,
}),
resumeToolRequest: vi.fn(),
};
const runner = createEmbeddedLobsterRunner({
loadRuntime: vi.fn().mockResolvedValue(runtime),
});
await runner.run({
action: "run",
pipeline: "daily inbox.lobster",
cwd: tempDir,
timeoutMs: 2000,
maxStdoutBytes: 4096,
});
expect(runtime.runToolRequest).toHaveBeenCalledOnce();
const request = requireRecord(
requireFirstCallParam(runtime.runToolRequest.mock.calls, "workflow file with spaces"),
"workflow file with spaces",
);
expect(request.filePath).toBe(workflowPath);
expect(request.pipeline).toBeUndefined();
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it.each([
["missing.lobster", "missing.lobster"],
["nested/missing.yaml", path.join("nested", "missing.yaml")],
])("surfaces missing workflow path errors for %s", async (pipeline, expectedRelativePath) => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-runner-"));
try {
const runtime = {
runToolRequest: vi.fn(),
resumeToolRequest: vi.fn(),
};
const runner = createEmbeddedLobsterRunner({
loadRuntime: vi.fn().mockResolvedValue(runtime),
});
await expect(
runner.run({
action: "run",
pipeline,
cwd: tempDir,
timeoutMs: 2000,
maxStdoutBytes: 4096,
}),
).rejects.toMatchObject({
code: "ENOENT",
path: path.join(tempDir, expectedRelativePath),
});
expect(runtime.runToolRequest).not.toHaveBeenCalled();
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("returns a parse error when workflow args are invalid JSON", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-runner-"));
const workflowPath = path.join(tempDir, "workflow.lobster");
await fs.writeFile(workflowPath, "steps: []\n", "utf8");
try {
const runtime = {
runToolRequest: vi.fn(),
resumeToolRequest: vi.fn(),
};
const runner = createEmbeddedLobsterRunner({
loadRuntime: vi.fn().mockResolvedValue(runtime),
});
await expect(
runner.run({
action: "run",
pipeline: "workflow.lobster",
argsJson: "{bad",
cwd: tempDir,
timeoutMs: 2000,
maxStdoutBytes: 4096,
}),
).rejects.toThrow("run --args-json must be valid JSON");
expect(runtime.runToolRequest).not.toHaveBeenCalled();
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("throws when the embedded runtime returns an error envelope", async () => {
const runtime = {
runToolRequest: vi.fn().mockResolvedValue({
ok: false,
protocolVersion: 1,
error: {
type: "runtime_error",
message: "boom",
},
}),
resumeToolRequest: vi.fn(),
};
const runner = createEmbeddedLobsterRunner({
loadRuntime: vi.fn().mockResolvedValue(runtime),
});
await expect(
runner.run({
action: "run",
pipeline: "exec --json=true echo hi",
cwd: process.cwd(),
timeoutMs: 2000,
maxStdoutBytes: 4096,
}),
).rejects.toThrow("boom");
});
it("fails closed when the embedded runtime requests unsupported input", async () => {
const runtime = {
runToolRequest: vi.fn().mockResolvedValue({
ok: true,
protocolVersion: 1,
status: "needs_input",
output: [],
requiresApproval: null,
requiresInput: {
prompt: "Need more data",
schema: { type: "string" },
},
}),
resumeToolRequest: vi.fn(),
};
const runner = createEmbeddedLobsterRunner({
loadRuntime: vi.fn().mockResolvedValue(runtime),
});
await expect(
runner.run({
action: "run",
pipeline: "exec --json=true echo hi",
cwd: process.cwd(),
timeoutMs: 2000,
maxStdoutBytes: 4096,
}),
).rejects.toThrow("Lobster input requests are not supported by the OpenClaw Lobster tool yet");
});
it("routes resume through the embedded runtime", async () => {
const runtime = {
runToolRequest: vi.fn(),
resumeToolRequest: vi.fn().mockResolvedValue({
ok: true,
protocolVersion: 1,
status: "cancelled",
output: [],
requiresApproval: null,
}),
};
const runner = createEmbeddedLobsterRunner({
loadRuntime: vi.fn().mockResolvedValue(runtime),
});
const envelope = await runner.run({
action: "resume",
token: "resume-token",
approve: false,
cwd: process.cwd(),
timeoutMs: 2000,
maxStdoutBytes: 4096,
});
expect(runtime.resumeToolRequest).toHaveBeenCalledOnce();
const request = requireRecord(
requireFirstCallParam(runtime.resumeToolRequest.mock.calls, "resume tool request"),
"resume tool request",
);
expect(request.token).toBe("resume-token");
expect(request.approved).toBe(false);
expectToolContext(request.ctx, { cwd: process.cwd(), mode: "tool" });
expect(envelope).toEqual({
ok: true,
status: "cancelled",
output: [],
requiresApproval: null,
});
});
it("forwards approvalId through resume when token is absent", async () => {
const runtime = {
runToolRequest: vi.fn(),
resumeToolRequest: vi.fn().mockResolvedValue({
ok: true,
protocolVersion: 1,
status: "ok",
output: [],
requiresApproval: null,
}),
};
const runner = createEmbeddedLobsterRunner({
loadRuntime: vi.fn().mockResolvedValue(runtime),
});
await runner.run({
action: "resume",
approvalId: "dbc98d05",
approve: true,
cwd: process.cwd(),
timeoutMs: 2000,
maxStdoutBytes: 4096,
});
expect(runtime.resumeToolRequest).toHaveBeenCalledOnce();
const request = requireRecord(
requireFirstCallParam(runtime.resumeToolRequest.mock.calls, "approval resume tool request"),
"approval resume tool request",
);
expect(request.approvalId).toBe("dbc98d05");
expect(request.approved).toBe(true);
expectToolContext(request.ctx, { mode: "tool" });
});
it("passes approvalId through the normalized needs_approval envelope", async () => {
const runtime = {
runToolRequest: vi.fn().mockResolvedValue({
ok: true,
protocolVersion: 1,
status: "needs_approval",
output: [],
requiresApproval: {
type: "approval_request",
prompt: "ok?",
items: [],
resumeToken: "eyJ...",
approvalId: "dbc98d05",
},
}),
resumeToolRequest: vi.fn(),
};
const runner = createEmbeddedLobsterRunner({
loadRuntime: vi.fn().mockResolvedValue(runtime),
});
const envelope = await runner.run({
action: "run",
pipeline: "exec --json=true echo hi",
cwd: process.cwd(),
timeoutMs: 2000,
maxStdoutBytes: 4096,
});
expect(envelope).toEqual({
ok: true,
status: "needs_approval",
output: [],
requiresApproval: {
type: "approval_request",
prompt: "ok?",
items: [],
resumeToken: "eyJ...",
approvalId: "dbc98d05",
},
});
});
it("loads the embedded runtime once per runner", async () => {
const runtime = {
runToolRequest: vi.fn().mockResolvedValue({
ok: true,
protocolVersion: 1,
status: "ok",
output: [],
requiresApproval: null,
}),
resumeToolRequest: vi.fn().mockResolvedValue({
ok: true,
protocolVersion: 1,
status: "cancelled",
output: [],
requiresApproval: null,
}),
};
const loadRuntime = vi.fn().mockResolvedValue(runtime);
const runner = createEmbeddedLobsterRunner({ loadRuntime });
await runner.run({
action: "run",
pipeline: "exec --json=true echo hi",
cwd: process.cwd(),
timeoutMs: 2000,
maxStdoutBytes: 4096,
});
await runner.run({
action: "resume",
token: "resume-token",
approve: false,
cwd: process.cwd(),
timeoutMs: 2000,
maxStdoutBytes: 4096,
});
expect(loadRuntime).toHaveBeenCalledTimes(1);
});
it("falls back to the installed package core file when the core export is unavailable", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-package-"));
const packageRoot = path.join(tempDir, "node_modules", "@clawdbot", "lobster");
const packageEntryPath = path.join(packageRoot, "dist", "src", "sdk", "index.js");
const packageCorePath = path.join(packageRoot, "dist", "src", "core", "index.js");
try {
await fs.mkdir(path.dirname(packageEntryPath), { recursive: true });
await fs.mkdir(path.dirname(packageCorePath), { recursive: true });
await fs.writeFile(
path.join(packageRoot, "package.json"),
JSON.stringify({
name: "@clawdbot/lobster",
type: "module",
main: "./dist/src/sdk/index.js",
}),
"utf8",
);
await fs.writeFile(packageEntryPath, "export {};\n", "utf8");
await fs.writeFile(
packageCorePath,
[
"export async function runToolRequest() {",
" return { ok: true, status: 'ok', output: [{ source: 'fallback' }], requiresApproval: null };",
"}",
"export async function resumeToolRequest() {",
" return { ok: true, status: 'cancelled', output: [], requiresApproval: null };",
"}",
"",
].join("\n"),
"utf8",
);
const runtime = await loadEmbeddedToolRuntimeFromPackage({
importModule: async (specifier) => {
if (specifier === "@clawdbot/lobster/core") {
throw new Error("package export missing");
}
return (await import(`${specifier}?t=${Date.now()}`)) as object;
},
resolvePackageEntry: () => packageEntryPath,
});
await expect(runtime.runToolRequest({ pipeline: "commands.list" })).resolves.toEqual({
ok: true,
status: "ok",
output: [{ source: "fallback" }],
requiresApproval: null,
});
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("installs an Ajv content cache before loading the embedded runtime", async () => {
const AjvCtor = await importLobsterAjvConstructor();
const ajv = new AjvCtor({ allErrors: true, strict: false, addUsedSchema: false });
const before = readAjvInternalCacheSize(ajv);
await loadEmbeddedToolRuntimeFromPackage({
importModule: async () => ({
runToolRequest: vi.fn(),
resumeToolRequest: vi.fn(),
}),
});
const first = ajv.compile(createRepeatedResponseSchema());
const second = ajv.compile(createRepeatedResponseSchema());
const afterRepeated = readAjvInternalCacheSize(ajv);
expect(second).toBe(first);
expect(afterRepeated - before).toBe(1);
for (let index = 0; index < 520; index += 1) {
ajv.compile(createUniqueResponseSchema(index));
}
expect(readAjvInternalCacheSize(ajv)).toBeLessThanOrEqual(before + 512);
});
it("deduplicates content-identical schema compilation in the installed Lobster runtime", async () => {
await loadEmbeddedToolRuntimeFromPackage();
const corePath = requireForTest.resolve("@clawdbot/lobster/core");
const validationPath = path.join(path.dirname(path.dirname(corePath)), "validation.js");
const validationModule = (await import(pathToFileURL(validationPath).href)) as {
sharedAjv: AjvInstance;
};
const before = readAjvInternalCacheSize(validationModule.sharedAjv);
const first = validationModule.sharedAjv.compile(createRepeatedResponseSchema());
for (let index = 0; index < 1000; index += 1) {
validationModule.sharedAjv.compile(createRepeatedResponseSchema());
}
const second = validationModule.sharedAjv.compile(createRepeatedResponseSchema());
expect(second).toBe(first);
expect(readAjvInternalCacheSize(validationModule.sharedAjv) - before).toBe(1);
});
it("requires a pipeline for run", async () => {
const runner = createEmbeddedLobsterRunner({
loadRuntime: vi.fn().mockResolvedValue({
runToolRequest: vi.fn(),
resumeToolRequest: vi.fn(),
}),
});
await expect(
runner.run({
action: "run",
cwd: process.cwd(),
timeoutMs: 2000,
maxStdoutBytes: 4096,
}),
).rejects.toThrow(/pipeline required/);
});
it("requires token and approve for resume", async () => {
const runner = createEmbeddedLobsterRunner({
loadRuntime: vi.fn().mockResolvedValue({
runToolRequest: vi.fn(),
resumeToolRequest: vi.fn(),
}),
});
await expect(
runner.run({
action: "resume",
approve: true,
cwd: process.cwd(),
timeoutMs: 2000,
maxStdoutBytes: 4096,
}),
).rejects.toThrow(/token or approvalId required/);
await expect(
runner.run({
action: "resume",
token: "resume-token",
cwd: process.cwd(),
timeoutMs: 2000,
maxStdoutBytes: 4096,
}),
).rejects.toThrow(/approve required/);
});
it("aborts long-running embedded work", async () => {
const runtime = {
runToolRequest: vi.fn(
async ({ ctx }: { ctx?: { signal?: AbortSignal } }) =>
await new Promise((resolve, reject) => {
const timeout = setTimeout(
() => resolve({ ok: true, status: "ok", output: [], requiresApproval: null }),
500,
);
ctx?.signal?.addEventListener("abort", () => {
clearTimeout(timeout);
reject(
toLintErrorObject(
ctx.signal?.reason ?? new Error("aborted"),
"Non-Error rejection",
),
);
});
}),
),
resumeToolRequest: vi.fn(),
};
const runner = createEmbeddedLobsterRunner({
loadRuntime: vi.fn().mockResolvedValue(runtime),
});
await expect(
runner.run({
action: "run",
pipeline: "exec --json=true echo hi",
cwd: process.cwd(),
timeoutMs: 200,
maxStdoutBytes: 4096,
}),
).rejects.toThrow(/timed out|aborted/);
});
});
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -0,0 +1,429 @@
// Lobster plugin module implements lobster runner behavior.
import { readFileSync } from "node:fs";
import { stat } from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
import { Readable, Writable } from "node:stream";
import { pathToFileURL } from "node:url";
import { installLobsterAjvCompileCache } from "./lobster-ajv-cache.js";
export type LobsterEnvelope =
| {
ok: true;
status: "ok" | "needs_approval" | "cancelled";
output: unknown[];
requiresApproval: null | {
type: "approval_request";
prompt: string;
items: unknown[];
resumeToken?: string;
approvalId?: string;
};
}
| {
ok: false;
error: { type?: string; message: string };
};
export type LobsterRunnerParams = {
action: "run" | "resume";
pipeline?: string;
argsJson?: string;
token?: string;
approvalId?: string;
approve?: boolean;
cwd: string;
timeoutMs: number;
maxStdoutBytes: number;
};
export type LobsterRunner = {
run: (params: LobsterRunnerParams) => Promise<LobsterEnvelope>;
};
type EmbeddedToolContext = {
cwd?: string;
env?: Record<string, string | undefined>;
mode?: "tool" | "human" | "sdk";
stdin?: NodeJS.ReadableStream;
stdout?: NodeJS.WritableStream;
stderr?: NodeJS.WritableStream;
signal?: AbortSignal;
registry?: unknown;
llmAdapters?: Record<string, unknown>;
};
type EmbeddedToolEnvelope = {
protocolVersion?: number;
ok: boolean;
status?: "ok" | "needs_approval" | "needs_input" | "cancelled";
output?: unknown[];
requiresApproval?: {
type?: "approval_request";
prompt: string;
items: unknown[];
preview?: string;
resumeToken?: string;
approvalId?: string;
} | null;
requiresInput?: {
prompt: string;
schema?: unknown;
items?: unknown[];
resumeToken?: string;
approvalId?: string;
} | null;
error?: {
type?: string;
message: string;
};
};
type EmbeddedToolRuntime = {
runToolRequest: (params: {
pipeline?: string;
filePath?: string;
args?: Record<string, unknown>;
ctx?: EmbeddedToolContext;
}) => Promise<EmbeddedToolEnvelope>;
resumeToolRequest: (params: {
token?: string;
approvalId?: string;
approved?: boolean;
response?: unknown;
cancel?: boolean;
ctx?: EmbeddedToolContext;
}) => Promise<EmbeddedToolEnvelope>;
};
type LoadEmbeddedToolRuntime = () => Promise<EmbeddedToolRuntime>;
type LoadEmbeddedToolRuntimeFromPackageOptions = {
importModule?: (specifier: string) => Promise<Partial<EmbeddedToolRuntime>>;
resolvePackageEntry?: (specifier: string) => string;
};
const lobsterRequire = createRequire(import.meta.url);
const workflowExts = new Set([".lobster", ".yaml", ".yml", ".json"]);
function toEmbeddedToolRuntime(
moduleExports: Partial<EmbeddedToolRuntime>,
source: string,
): EmbeddedToolRuntime {
const { runToolRequest, resumeToolRequest } = moduleExports;
if (typeof runToolRequest === "function" && typeof resumeToolRequest === "function") {
return { runToolRequest, resumeToolRequest };
}
throw new Error(`${source} does not export Lobster embedded runtime functions`);
}
function findLobsterPackageRoot(resolvedEntryPath: string): string {
let dir = path.dirname(resolvedEntryPath);
while (true) {
const packageJsonPath = path.join(dir, "package.json");
try {
const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name?: string };
if (parsed.name === "@clawdbot/lobster") {
return dir;
}
} catch {
// Keep walking until the installed package root is found.
}
const parent = path.dirname(dir);
if (parent === dir) {
throw new Error(`Could not locate @clawdbot/lobster package root from ${resolvedEntryPath}`);
}
dir = parent;
}
}
function normalizeForCwdSandbox(p: string): string {
const normalized = path.normalize(p);
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
}
export function resolveLobsterCwd(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;
}
function createLimitedSink(maxBytes: number, label: "stdout" | "stderr") {
let bytes = 0;
return new Writable({
write(chunk, _encoding, callback) {
bytes += Buffer.byteLength(String(chunk), "utf8");
if (bytes > maxBytes) {
callback(new Error(`lobster ${label} exceeded maxStdoutBytes`));
return;
}
callback();
},
});
}
function normalizeEnvelope(envelope: EmbeddedToolEnvelope): LobsterEnvelope {
if (envelope.ok) {
if (envelope.status === "needs_input") {
return {
ok: false,
error: {
type: "unsupported_status",
message: "Lobster input requests are not supported by the OpenClaw Lobster tool yet",
},
};
}
return {
ok: true,
status: envelope.status ?? "ok",
output: Array.isArray(envelope.output) ? envelope.output : [],
requiresApproval: envelope.requiresApproval
? {
type: "approval_request",
prompt: envelope.requiresApproval.prompt,
items: envelope.requiresApproval.items,
...(envelope.requiresApproval.resumeToken
? { resumeToken: envelope.requiresApproval.resumeToken }
: {}),
...(envelope.requiresApproval.approvalId
? { approvalId: envelope.requiresApproval.approvalId }
: {}),
}
: null,
};
}
return {
ok: false,
error: {
type: envelope.error?.type,
message: envelope.error?.message ?? "lobster runtime failed",
},
};
}
function throwOnErrorEnvelope(envelope: LobsterEnvelope): Extract<LobsterEnvelope, { ok: true }> {
if (envelope.ok) {
return envelope;
}
throw new Error(envelope.error.message);
}
async function resolveWorkflowFile(candidate: string, cwd: string) {
const resolved = path.isAbsolute(candidate) ? candidate : path.resolve(cwd, candidate);
const fileStat = await stat(resolved);
if (!fileStat.isFile()) {
throw new Error("Workflow path is not a file");
}
const ext = path.extname(resolved).toLowerCase();
if (!workflowExts.has(ext)) {
throw new Error("Workflow file must end in .lobster, .yaml, .yml, or .json");
}
return resolved;
}
function isMissingPathError(error: unknown) {
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
(error as { code?: unknown }).code === "ENOENT"
);
}
function hasWorkflowFileExtension(candidate: string) {
return workflowExts.has(path.extname(candidate).toLowerCase());
}
async function detectWorkflowFile(candidate: string, cwd: string) {
const trimmed = candidate.trim();
if (!trimmed || trimmed.includes("|") || !hasWorkflowFileExtension(trimmed)) {
return null;
}
if (!/\s/.test(trimmed)) {
return await resolveWorkflowFile(trimmed, cwd);
}
try {
return await resolveWorkflowFile(trimmed, cwd);
} catch (error) {
if (isMissingPathError(error)) {
return null;
}
throw error;
}
}
function parseWorkflowArgs(argsJson: string) {
return JSON.parse(argsJson) as Record<string, unknown>;
}
function createEmbeddedToolContext(
params: LobsterRunnerParams,
signal?: AbortSignal,
): EmbeddedToolContext {
const env = { ...process.env } as Record<string, string | undefined>;
return {
cwd: params.cwd,
env,
mode: "tool",
stdin: Readable.from([]),
stdout: createLimitedSink(Math.max(1024, params.maxStdoutBytes), "stdout"),
stderr: createLimitedSink(Math.max(1024, params.maxStdoutBytes), "stderr"),
signal,
};
}
async function withTimeout<T>(
timeoutMs: number,
fn: (signal?: AbortSignal) => Promise<T>,
): Promise<T> {
const timeout = Math.max(200, timeoutMs);
const controller = new AbortController();
return await new Promise<T>((resolve, reject) => {
const onTimeout = () => {
const error = new Error("lobster runtime timed out");
controller.abort(error);
reject(error);
};
const timer = setTimeout(onTimeout, timeout);
void fn(controller.signal).then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error: unknown) => {
clearTimeout(timer);
reject(toLintErrorObject(error, "Non-Error rejection"));
},
);
});
}
export async function loadEmbeddedToolRuntimeFromPackage(
options: LoadEmbeddedToolRuntimeFromPackageOptions = {},
): Promise<EmbeddedToolRuntime> {
const importModule =
options.importModule ??
(async (specifier: string) => (await import(specifier)) as Partial<EmbeddedToolRuntime>);
const resolvePackageEntry =
options.resolvePackageEntry ?? ((specifier: string) => lobsterRequire.resolve(specifier));
const packageEntryPath = resolvePackageEntry("@clawdbot/lobster");
await installLobsterAjvCompileCache(packageEntryPath);
let coreLoadError: unknown;
try {
const coreSpecifier = ["@clawdbot", "lobster", "core"].join("/");
return toEmbeddedToolRuntime(await importModule(coreSpecifier), "@clawdbot/lobster/core");
} catch (error) {
coreLoadError = error;
}
let fallbackLoadError: unknown;
try {
const packageRoot = findLobsterPackageRoot(packageEntryPath);
const coreRuntimeUrl = pathToFileURL(path.join(packageRoot, "dist/src/core/index.js")).href;
return toEmbeddedToolRuntime(await importModule(coreRuntimeUrl), coreRuntimeUrl);
} catch (error) {
fallbackLoadError = error;
}
throw new Error("Failed to load the Lobster embedded runtime", {
cause: new AggregateError(
[coreLoadError, fallbackLoadError],
"Both Lobster embedded runtime load paths failed",
),
});
}
export function createEmbeddedLobsterRunner(options?: {
loadRuntime?: LoadEmbeddedToolRuntime;
}): LobsterRunner {
const loadRuntime = options?.loadRuntime ?? loadEmbeddedToolRuntimeFromPackage;
let runtimePromise: Promise<EmbeddedToolRuntime> | undefined;
return {
async run(params) {
runtimePromise ??= loadRuntime();
const runtime = await runtimePromise;
return await withTimeout(params.timeoutMs, async (signal) => {
const ctx = createEmbeddedToolContext(params, signal);
if (params.action === "run") {
const pipeline = params.pipeline?.trim() ?? "";
if (!pipeline) {
throw new Error("pipeline required");
}
const filePath = await detectWorkflowFile(pipeline, params.cwd);
if (filePath) {
const parsedArgsJson = params.argsJson?.trim() ?? "";
let args: Record<string, unknown> | undefined;
if (parsedArgsJson) {
try {
args = parseWorkflowArgs(parsedArgsJson);
} catch {
throw new Error("run --args-json must be valid JSON");
}
}
return throwOnErrorEnvelope(
normalizeEnvelope(await runtime.runToolRequest({ filePath, args, ctx })),
);
}
return throwOnErrorEnvelope(
normalizeEnvelope(await runtime.runToolRequest({ pipeline, ctx })),
);
}
const token = params.token?.trim() ?? "";
const approvalId = params.approvalId?.trim() ?? "";
if (!token && !approvalId) {
throw new Error("token or approvalId required");
}
if (typeof params.approve !== "boolean") {
throw new Error("approve required");
}
return throwOnErrorEnvelope(
normalizeEnvelope(
await runtime.resumeToolRequest({
...(token ? { token } : {}),
...(approvalId ? { approvalId } : {}),
approved: params.approve,
ctx,
}),
),
);
});
},
};
}
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -0,0 +1,228 @@
// Lobster tests cover lobster taskflow plugin behavior.
import { describe, expect, it, vi } from "vitest";
import type { LobsterRunner } from "./lobster-runner.js";
import { resumeManagedLobsterFlow, runManagedLobsterFlow } from "./lobster-taskflow.js";
import { createFakeTaskFlow } from "./taskflow-test-helpers.js";
function expectManagedFlowFailure(
result: Awaited<ReturnType<typeof runManagedLobsterFlow | typeof resumeManagedLobsterFlow>>,
) {
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error("Expected managed Lobster flow to fail");
}
return result;
}
function createRunner(result: Awaited<ReturnType<LobsterRunner["run"]>>): LobsterRunner {
return {
run: vi.fn().mockResolvedValue(result),
};
}
function createRunFlowParams(
taskFlow: ReturnType<typeof createFakeTaskFlow>,
runner: LobsterRunner,
): Parameters<typeof runManagedLobsterFlow>[0] {
return {
taskFlow,
runner,
runnerParams: {
action: "run",
pipeline: "noop",
cwd: process.cwd(),
timeoutMs: 1000,
maxStdoutBytes: 4096,
},
controllerId: "tests/lobster",
goal: "Run Lobster workflow",
};
}
function createResumeFlowParams(
taskFlow: ReturnType<typeof createFakeTaskFlow>,
runner: LobsterRunner,
): Parameters<typeof resumeManagedLobsterFlow>[0] {
return {
taskFlow,
runner,
flowId: "flow-1",
expectedRevision: 4,
runnerParams: {
action: "resume",
token: "resume-1",
approve: true,
cwd: process.cwd(),
timeoutMs: 1000,
maxStdoutBytes: 4096,
},
};
}
describe("runManagedLobsterFlow", () => {
it("creates a flow and finishes it when Lobster succeeds", async () => {
const taskFlow = createFakeTaskFlow();
const runner = createRunner({
ok: true,
status: "ok",
output: [{ id: "result-1" }],
requiresApproval: null,
});
const result = await runManagedLobsterFlow(createRunFlowParams(taskFlow, runner));
expect(result.ok).toBe(true);
expect(taskFlow.createManaged).toHaveBeenCalledWith({
controllerId: "tests/lobster",
goal: "Run Lobster workflow",
currentStep: "run_lobster",
});
expect(taskFlow.finish).toHaveBeenCalledWith({
flowId: "flow-1",
expectedRevision: 1,
});
});
it("moves the flow to waiting when Lobster requests approval", async () => {
const taskFlow = createFakeTaskFlow();
const createdAt = new Date("2026-04-05T21:00:00.000Z");
const runner = createRunner({
ok: true,
status: "needs_approval",
output: [],
requiresApproval: {
type: "approval_request",
prompt: "Approve this?",
items: [{ id: "item-1", createdAt, count: 2n, skip: undefined }],
resumeToken: "resume-1",
},
});
const result = await runManagedLobsterFlow(createRunFlowParams(taskFlow, runner));
expect(result.ok).toBe(true);
expect(taskFlow.setWaiting).toHaveBeenCalledWith({
flowId: "flow-1",
expectedRevision: 1,
currentStep: "await_lobster_approval",
waitJson: {
kind: "lobster_approval",
prompt: "Approve this?",
items: [{ id: "item-1", createdAt: createdAt.toISOString(), count: "2" }],
resumeToken: "resume-1",
},
});
});
it("fails the flow when Lobster returns an error envelope", async () => {
const taskFlow = createFakeTaskFlow();
const runner = createRunner({
ok: false,
error: {
type: "runtime_error",
message: "boom",
},
});
const result = expectManagedFlowFailure(
await runManagedLobsterFlow(createRunFlowParams(taskFlow, runner)),
);
expect(result.error.message).toBe("boom");
expect(taskFlow.fail).toHaveBeenCalledWith({
flowId: "flow-1",
expectedRevision: 1,
});
});
it("fails the flow when the runner throws", async () => {
const taskFlow = createFakeTaskFlow();
const runner: LobsterRunner = {
run: vi.fn().mockRejectedValue(new Error("crashed")),
};
const result = expectManagedFlowFailure(
await runManagedLobsterFlow(createRunFlowParams(taskFlow, runner)),
);
expect(result.error.message).toBe("crashed");
expect(taskFlow.fail).toHaveBeenCalledWith({
flowId: "flow-1",
expectedRevision: 1,
});
});
});
describe("resumeManagedLobsterFlow", () => {
it("resumes the flow and finishes it on success", async () => {
const taskFlow = createFakeTaskFlow();
const runner = createRunner({
ok: true,
status: "ok",
output: [],
requiresApproval: null,
});
const result = await resumeManagedLobsterFlow(createResumeFlowParams(taskFlow, runner));
expect(result.ok).toBe(true);
expect(taskFlow.resume).toHaveBeenCalledWith({
flowId: "flow-1",
expectedRevision: 4,
status: "running",
currentStep: "resume_lobster",
});
expect(taskFlow.finish).toHaveBeenCalledWith({
flowId: "flow-1",
expectedRevision: 5,
});
});
it("returns a mutation error when taskFlow resume is rejected", async () => {
const taskFlow = createFakeTaskFlow({
resume: vi.fn().mockReturnValue({
applied: false,
code: "revision_conflict",
}),
});
const runner = createRunner({
ok: true,
status: "ok",
output: [],
requiresApproval: null,
});
const result = expectManagedFlowFailure(
await resumeManagedLobsterFlow(createResumeFlowParams(taskFlow, runner)),
);
expect(result.error.message).toMatch(/revision_conflict/);
expect(runner.run).not.toHaveBeenCalled();
});
it("returns to waiting when the resumed Lobster run needs approval again", async () => {
const taskFlow = createFakeTaskFlow();
const runner = createRunner({
ok: true,
status: "needs_approval",
output: [],
requiresApproval: {
type: "approval_request",
prompt: "Approve this too?",
items: [{ id: "item-2" }],
resumeToken: "resume-2",
},
});
const result = await resumeManagedLobsterFlow(createResumeFlowParams(taskFlow, runner));
expect(result.ok).toBe(true);
expect(taskFlow.setWaiting).toHaveBeenCalledWith({
flowId: "flow-1",
expectedRevision: 5,
currentStep: "await_lobster_approval",
waitJson: {
kind: "lobster_approval",
prompt: "Approve this too?",
items: [{ id: "item-2" }],
resumeToken: "resume-2",
},
});
});
});

View File

@@ -0,0 +1,289 @@
// Lobster plugin module implements lobster taskflow behavior.
import type { OpenClawPluginApi } from "../runtime-api.js";
import type { LobsterEnvelope, LobsterRunner, LobsterRunnerParams } from "./lobster-runner.js";
type JsonLike =
| null
| boolean
| number
| string
| JsonLike[]
| {
[key: string]: JsonLike;
};
type BoundTaskFlow = ReturnType<
NonNullable<OpenClawPluginApi["runtime"]>["tasks"]["managedFlows"]["bindSession"]
>;
type FlowRecord = NonNullable<ReturnType<BoundTaskFlow["tryCreateManaged"]>>;
type MutationResult = ReturnType<BoundTaskFlow["setWaiting"]>;
type LobsterApprovalWaitState = {
kind: "lobster_approval";
prompt: string;
items: JsonLike[];
resumeToken?: string;
approvalId?: string;
};
type RunManagedLobsterFlowParams = {
taskFlow: BoundTaskFlow;
runner: LobsterRunner;
runnerParams: LobsterRunnerParams;
controllerId: string;
goal: string;
stateJson?: JsonLike;
currentStep?: string;
waitingStep?: string;
};
type ResumeManagedLobsterFlowParams = {
taskFlow: BoundTaskFlow;
runner: LobsterRunner;
runnerParams: LobsterRunnerParams & {
action: "resume";
approve: boolean;
} & ({ token: string } | { approvalId: string });
flowId: string;
expectedRevision: number;
currentStep?: string;
waitingStep?: string;
};
export type ManagedLobsterFlowResult =
| {
ok: true;
envelope: LobsterEnvelope;
flow: FlowRecord;
mutation: MutationResult;
}
| {
ok: false;
flow?: FlowRecord;
mutation?: MutationResult;
error: Error;
};
function toJsonLike(value: unknown, seen = new WeakSet<object>()): JsonLike {
if (value === null) {
return null;
}
switch (typeof value) {
case "boolean":
case "string":
return value;
case "number":
return Number.isFinite(value) ? value : String(value);
case "bigint":
return value.toString();
case "undefined":
case "function":
case "symbol":
return null;
case "object": {
if (value instanceof Date) {
return value.toISOString();
}
if (Array.isArray(value)) {
return value.map((item) => toJsonLike(item, seen));
}
if (seen.has(value)) {
return "[Circular]";
}
seen.add(value);
const jsonObject: Record<string, JsonLike> = {};
for (const [key, entry] of Object.entries(value)) {
if (entry === undefined || typeof entry === "function" || typeof entry === "symbol") {
continue;
}
jsonObject[key] = toJsonLike(entry, seen);
}
seen.delete(value);
return jsonObject;
}
}
return null;
}
function buildApprovalWaitState(envelope: Extract<LobsterEnvelope, { ok: true }>): JsonLike {
if (!envelope.requiresApproval) {
return {
kind: "lobster_approval",
prompt: "",
items: [],
} satisfies LobsterApprovalWaitState;
}
return {
kind: "lobster_approval",
prompt: envelope.requiresApproval.prompt,
items: envelope.requiresApproval.items.map((item) => toJsonLike(item)),
...(envelope.requiresApproval.resumeToken
? { resumeToken: envelope.requiresApproval.resumeToken }
: {}),
...(envelope.requiresApproval.approvalId
? { approvalId: envelope.requiresApproval.approvalId }
: {}),
} satisfies LobsterApprovalWaitState;
}
function applyEnvelopeToFlow(params: {
taskFlow: BoundTaskFlow;
flow: FlowRecord;
envelope: LobsterEnvelope;
waitingStep: string;
}): MutationResult {
const { taskFlow, flow, envelope, waitingStep } = params;
if (!envelope.ok) {
return taskFlow.fail({
flowId: flow.flowId,
expectedRevision: flow.revision,
});
}
if (envelope.status === "needs_approval") {
return taskFlow.setWaiting({
flowId: flow.flowId,
expectedRevision: flow.revision,
currentStep: waitingStep,
waitJson: buildApprovalWaitState(envelope),
});
}
return taskFlow.finish({
flowId: flow.flowId,
expectedRevision: flow.revision,
});
}
function buildEnvelopeError(envelope: Extract<LobsterEnvelope, { ok: false }>) {
return new Error(envelope.error.message);
}
export async function runManagedLobsterFlow(
params: RunManagedLobsterFlowParams,
): Promise<ManagedLobsterFlowResult> {
const createFlowParams = {
controllerId: params.controllerId,
goal: params.goal,
currentStep: params.currentStep ?? "run_lobster",
...(params.stateJson !== undefined ? { stateJson: params.stateJson } : {}),
};
const flow = params.taskFlow.tryCreateManaged
? params.taskFlow.tryCreateManaged(createFlowParams)
: params.taskFlow.createManaged(createFlowParams);
if (!flow) {
return {
ok: false,
error: new Error("TaskFlow persistence failed."),
};
}
try {
const envelope = await params.runner.run(params.runnerParams);
const mutation = applyEnvelopeToFlow({
taskFlow: params.taskFlow,
flow,
envelope,
waitingStep: params.waitingStep ?? "await_lobster_approval",
});
if (!envelope.ok) {
return {
ok: false,
flow,
mutation,
error: buildEnvelopeError(envelope),
};
}
return {
ok: true,
envelope,
flow,
mutation,
};
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
try {
const mutation = params.taskFlow.fail({
flowId: flow.flowId,
expectedRevision: flow.revision,
});
return {
ok: false,
flow,
mutation,
error: err,
};
} catch {
return {
ok: false,
flow,
error: err,
};
}
}
}
export async function resumeManagedLobsterFlow(
params: ResumeManagedLobsterFlowParams,
): Promise<ManagedLobsterFlowResult> {
const resumed = params.taskFlow.resume({
flowId: params.flowId,
expectedRevision: params.expectedRevision,
status: "running",
currentStep: params.currentStep ?? "resume_lobster",
});
if (!resumed.applied) {
return {
ok: false,
mutation: resumed,
error: new Error(`TaskFlow resume failed: ${resumed.code}`),
};
}
try {
const envelope = await params.runner.run(params.runnerParams);
const mutation = applyEnvelopeToFlow({
taskFlow: params.taskFlow,
flow: resumed.flow,
envelope,
waitingStep: params.waitingStep ?? "await_lobster_approval",
});
if (!envelope.ok) {
return {
ok: false,
flow: resumed.flow,
mutation,
error: buildEnvelopeError(envelope),
};
}
return {
ok: true,
envelope,
flow: resumed.flow,
mutation,
};
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
try {
const mutation = params.taskFlow.fail({
flowId: params.flowId,
expectedRevision: resumed.flow.revision,
});
return {
ok: false,
flow: resumed.flow,
mutation,
error: err,
};
} catch {
return {
ok: false,
flow: resumed.flow,
error: err,
};
}
}
}

View File

@@ -0,0 +1,430 @@
// Lobster tests cover lobster tool plugin behavior.
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../runtime-api.js";
import { createLobsterTool } from "./lobster-tool.js";
import { createFakeTaskFlow } from "./taskflow-test-helpers.js";
function fakeApi(overrides: Partial<OpenClawPluginApi> = {}): OpenClawPluginApi {
return createTestPluginApi({
id: "lobster",
name: "lobster",
source: "test",
runtime: { version: "test" } as any,
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,
};
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`expected ${label} to be a record`);
}
return value as Record<string, unknown>;
}
describe("lobster plugin tool", () => {
it("returns the Lobster envelope in details", async () => {
const runner = {
run: vi.fn().mockResolvedValue({
ok: true,
status: "ok",
output: [{ hello: "world" }],
requiresApproval: null,
}),
};
const tool = createLobsterTool(fakeApi(), { runner });
const res = await tool.execute("call1", {
action: "run",
pipeline: "noop",
timeoutMs: 1000,
});
expect(runner.run).toHaveBeenCalledWith({
action: "run",
pipeline: "noop",
cwd: process.cwd(),
timeoutMs: 1000,
maxStdoutBytes: 512_000,
});
const details = requireRecord(res.details, "lobster tool details");
expect(details.ok).toBe(true);
expect(details.status).toBe("ok");
expect(details.output).toEqual([{ hello: "world" }]);
expect(details.requiresApproval).toBeNull();
});
it("supports approval envelopes without changing the tool contract", async () => {
const runner = {
run: vi.fn().mockResolvedValue({
ok: true,
status: "needs_approval",
output: [],
requiresApproval: {
type: "approval_request",
prompt: "Send these alerts?",
items: [{ id: "alert-1" }],
resumeToken: "resume-token-1",
},
}),
};
const tool = createLobsterTool(fakeApi(), { runner });
const res = await tool.execute("call-injected-runner", {
action: "run",
pipeline: "noop",
argsJson: '{"since_hours":1}',
timeoutMs: 1500,
maxStdoutBytes: 4096,
});
expect(runner.run).toHaveBeenCalledWith({
action: "run",
pipeline: "noop",
argsJson: '{"since_hours":1}',
cwd: process.cwd(),
timeoutMs: 1500,
maxStdoutBytes: 4096,
});
const details = requireRecord(res.details, "approval lobster tool details");
expect(details.ok).toBe(true);
expect(details.status).toBe("needs_approval");
const approval = requireRecord(details.requiresApproval, "approval request");
expect(approval.type).toBe("approval_request");
expect(approval.prompt).toBe("Send these alerts?");
expect(approval.resumeToken).toBe("resume-token-1");
});
it("normalizes numeric string run limits before invoking the runner", async () => {
const runner = {
run: vi.fn().mockResolvedValue({
ok: true,
status: "ok",
output: [],
requiresApproval: null,
}),
};
const tool = createLobsterTool(fakeApi(), { runner });
await tool.execute("call-string-limits", {
action: "run",
pipeline: "noop",
timeoutMs: "1500",
maxStdoutBytes: "4096",
});
expect(runner.run).toHaveBeenCalledWith({
action: "run",
pipeline: "noop",
cwd: process.cwd(),
timeoutMs: 1500,
maxStdoutBytes: 4096,
});
});
it("rejects malformed numeric run limits before invoking the runner", async () => {
const runner = { run: vi.fn() };
const tool = createLobsterTool(fakeApi(), { runner });
await expect(
tool.execute("call-bad-timeout", {
action: "run",
pipeline: "noop",
timeoutMs: "1500.5",
}),
).rejects.toThrow("timeoutMs must be a positive integer");
await expect(
tool.execute("call-bad-stdout", {
action: "run",
pipeline: "noop",
maxStdoutBytes: 0,
}),
).rejects.toThrow("maxStdoutBytes must be a positive integer");
expect(runner.run).not.toHaveBeenCalled();
});
it("throws when the runner returns an error envelope", async () => {
const tool = createLobsterTool(fakeApi(), {
runner: {
run: vi.fn().mockResolvedValue({
ok: false,
error: {
type: "runtime_error",
message: "boom",
},
}),
},
});
await expect(
tool.execute("call-runner-error", {
action: "run",
pipeline: "noop",
}),
).rejects.toThrow("boom");
});
it("can run through managed TaskFlow mode", async () => {
const runner = {
run: vi.fn().mockResolvedValue({
ok: true,
status: "needs_approval",
output: [],
requiresApproval: {
type: "approval_request",
prompt: "Approve this?",
items: [{ id: "item-1" }],
resumeToken: "resume-1",
approvalId: "approval-1",
},
}),
};
const taskFlow = createFakeTaskFlow();
const tool = createLobsterTool(fakeApi(), { runner, taskFlow });
const res = await tool.execute("call-managed-run", {
action: "run",
pipeline: "noop",
flowControllerId: "tests/lobster",
flowGoal: "Run Lobster workflow",
flowStateJson: '{"lane":"email"}',
flowCurrentStep: "run_lobster",
flowWaitingStep: "await_review",
});
expect(taskFlow.createManaged).toHaveBeenCalledWith({
controllerId: "tests/lobster",
goal: "Run Lobster workflow",
currentStep: "run_lobster",
stateJson: { lane: "email" },
});
expect(taskFlow.setWaiting).toHaveBeenCalledWith({
flowId: "flow-1",
expectedRevision: 1,
currentStep: "await_review",
waitJson: {
kind: "lobster_approval",
prompt: "Approve this?",
items: [{ id: "item-1" }],
resumeToken: "resume-1",
approvalId: "approval-1",
},
});
const details = requireRecord(res.details, "managed run lobster tool details");
expect(details.ok).toBe(true);
expect(details.status).toBe("needs_approval");
const flow = requireRecord(details.flow, "managed run flow details");
expect(flow.flowId).toBe("flow-1");
const mutation = requireRecord(details.mutation, "managed run mutation details");
expect(mutation.applied).toBe(true);
});
it("rejects managed TaskFlow params when no bound taskFlow runtime is available", async () => {
const tool = createLobsterTool(fakeApi(), {
runner: { run: vi.fn() },
});
await expect(
tool.execute("call-missing-taskflow", {
action: "run",
pipeline: "noop",
flowControllerId: "tests/lobster",
flowGoal: "Run Lobster workflow",
}),
).rejects.toThrow(/Managed TaskFlow run mode requires a bound taskFlow runtime/);
});
it("rejects invalid flowStateJson in managed TaskFlow mode", async () => {
const tool = createLobsterTool(fakeApi(), {
runner: { run: vi.fn() },
taskFlow: createFakeTaskFlow(),
});
await expect(
tool.execute("call-invalid-flow-json", {
action: "run",
pipeline: "noop",
flowControllerId: "tests/lobster",
flowGoal: "Run Lobster workflow",
flowStateJson: "{bad",
}),
).rejects.toThrow(/flowStateJson must be valid JSON/);
});
it("can resume managed TaskFlow mode with only approvalId", async () => {
const runner = {
run: vi.fn().mockResolvedValue({
ok: true,
status: "ok",
output: [],
requiresApproval: null,
}),
};
const taskFlow = createFakeTaskFlow();
const tool = createLobsterTool(fakeApi(), { runner, taskFlow });
const res = await tool.execute("call-managed-resume-approval-id", {
action: "resume",
approvalId: "approval-1",
approve: true,
flowId: "flow-1",
flowExpectedRevision: 1,
flowCurrentStep: "resume_lobster",
});
expect(taskFlow.resume).toHaveBeenCalledWith({
flowId: "flow-1",
expectedRevision: 1,
status: "running",
currentStep: "resume_lobster",
});
expect(runner.run).toHaveBeenCalledWith({
action: "resume",
approvalId: "approval-1",
approve: true,
cwd: process.cwd(),
timeoutMs: 20_000,
maxStdoutBytes: 512_000,
});
const details = requireRecord(res.details, "managed resume lobster tool details");
expect(details.ok).toBe(true);
expect(details.status).toBe("ok");
const mutation = requireRecord(details.mutation, "managed resume mutation details");
expect(mutation.applied).toBe(true);
});
it("normalizes numeric string flowExpectedRevision before managed resume", async () => {
const runner = {
run: vi.fn().mockResolvedValue({
ok: true,
status: "ok",
output: [],
requiresApproval: null,
}),
};
const taskFlow = createFakeTaskFlow();
const tool = createLobsterTool(fakeApi(), { runner, taskFlow });
await tool.execute("call-managed-resume-string-revision", {
action: "resume",
approvalId: "approval-1",
approve: true,
flowId: "flow-1",
flowExpectedRevision: "1",
flowCurrentStep: "resume_lobster",
});
expect(taskFlow.resume).toHaveBeenCalledWith({
flowId: "flow-1",
expectedRevision: 1,
status: "running",
currentStep: "resume_lobster",
});
});
it("rejects managed TaskFlow resume mode without a token or approvalId", async () => {
const tool = createLobsterTool(fakeApi(), {
runner: { run: vi.fn() },
taskFlow: createFakeTaskFlow(),
});
await expect(
tool.execute("call-missing-resume-token", {
action: "resume",
flowId: "flow-1",
flowExpectedRevision: 1,
approve: true,
}),
).rejects.toThrow(/token or approvalId required when using managed TaskFlow resume mode/);
});
it("rejects managed TaskFlow resume mode without approve", async () => {
const tool = createLobsterTool(fakeApi(), {
runner: { run: vi.fn() },
taskFlow: createFakeTaskFlow(),
});
await expect(
tool.execute("call-missing-resume-approve", {
action: "resume",
token: "resume-token",
flowId: "flow-1",
flowExpectedRevision: 1,
}),
).rejects.toThrow(/approve required when using managed TaskFlow resume mode/);
});
it("requires action", async () => {
const tool = createLobsterTool(fakeApi(), {
runner: { run: vi.fn() },
});
await expect(tool.execute("call-action-missing", {})).rejects.toThrow(/action required/);
});
it("rejects unknown action", async () => {
const tool = createLobsterTool(fakeApi(), {
runner: { run: vi.fn() },
});
await expect(
tool.execute("call-action-unknown", {
action: "explode",
}),
).rejects.toThrow(/Unknown action/);
});
it("rejects absolute cwd", async () => {
const tool = createLobsterTool(fakeApi(), {
runner: { run: vi.fn() },
});
await expect(
tool.execute("call-absolute-cwd", {
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(), {
runner: { run: vi.fn() },
});
await expect(
tool.execute("call-escape-cwd", {
action: "run",
pipeline: "noop",
cwd: "../../etc",
}),
).rejects.toThrow(/must stay within/);
});
it("can be gated off in sandboxed contexts", () => {
const api = fakeApi();
const factoryTool = (ctx: OpenClawPluginToolContext) => {
if (ctx.sandboxed) {
return null;
}
return createLobsterTool(api, {
runner: { run: vi.fn() },
});
};
expect(factoryTool(fakeCtx({ sandboxed: true }))).toBeNull();
expect(factoryTool(fakeCtx({ sandboxed: false }))?.name).toBe("lobster");
});
});

View File

@@ -0,0 +1,319 @@
// Lobster plugin module implements lobster tool behavior.
import {
optionalNonNegativeIntegerSchema,
optionalPositiveIntegerSchema,
} from "openclaw/plugin-sdk/channel-actions";
import {
readNonNegativeIntegerParam,
readPositiveIntegerParam,
} from "openclaw/plugin-sdk/param-readers";
import { jsonResult } from "openclaw/plugin-sdk/tool-results";
import { Type } from "typebox";
import type { OpenClawPluginApi } from "../runtime-api.js";
import {
createEmbeddedLobsterRunner,
resolveLobsterCwd,
type LobsterRunner,
type LobsterRunnerParams,
} from "./lobster-runner.js";
import {
type ManagedLobsterFlowResult,
resumeManagedLobsterFlow,
runManagedLobsterFlow,
} from "./lobster-taskflow.js";
type BoundTaskFlow = ReturnType<
NonNullable<OpenClawPluginApi["runtime"]>["tasks"]["managedFlows"]["bindSession"]
>;
type JsonLike =
| null
| boolean
| number
| string
| JsonLike[]
| {
[key: string]: JsonLike;
};
type LobsterToolOptions = {
runner?: LobsterRunner;
taskFlow?: BoundTaskFlow;
};
type ManagedFlowRunParams = {
controllerId: string;
goal: string;
currentStep?: string;
waitingStep?: string;
stateJson?: JsonLike;
};
type ManagedFlowResumeParams = {
flowId: string;
expectedRevision: number;
currentStep?: string;
waitingStep?: string;
};
type ManagedFlowSuccessResult = {
ok: true;
envelope: unknown;
flow: unknown;
mutation: unknown;
};
function readOptionalTrimmedString(value: unknown, fieldName: string): string | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== "string") {
throw new Error(`${fieldName} must be a string`);
}
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
function readOptionalNumber(value: unknown, fieldName: string): number | undefined {
return readNonNegativeIntegerParam({ [fieldName]: value }, fieldName, {
message: `${fieldName} must be a non-negative integer`,
});
}
function readOptionalBoolean(value: unknown, fieldName: string): boolean | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== "boolean") {
throw new Error(`${fieldName} must be a boolean`);
}
return value;
}
function parseOptionalFlowStateJson(value: unknown): JsonLike | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== "string") {
throw new Error("flowStateJson must be a JSON string");
}
try {
return JSON.parse(value) as JsonLike;
} catch {
throw new Error("flowStateJson must be valid JSON");
}
}
function parseRunFlowParams(params: Record<string, unknown>): ManagedFlowRunParams | null {
const controllerId = readOptionalTrimmedString(params.flowControllerId, "flowControllerId");
const goal = readOptionalTrimmedString(params.flowGoal, "flowGoal");
const currentStep = readOptionalTrimmedString(params.flowCurrentStep, "flowCurrentStep");
const waitingStep = readOptionalTrimmedString(params.flowWaitingStep, "flowWaitingStep");
const stateJson = parseOptionalFlowStateJson(params.flowStateJson);
const resumeFlowId = readOptionalTrimmedString(params.flowId, "flowId");
const resumeRevision = readOptionalNumber(params.flowExpectedRevision, "flowExpectedRevision");
const hasRunFields =
controllerId !== undefined ||
goal !== undefined ||
currentStep !== undefined ||
waitingStep !== undefined ||
stateJson !== undefined;
if (!hasRunFields) {
return null;
}
if (resumeFlowId !== undefined || resumeRevision !== undefined) {
throw new Error("run action does not accept flowId or flowExpectedRevision");
}
if (!controllerId) {
throw new Error("flowControllerId required when using managed TaskFlow run mode");
}
if (!goal) {
throw new Error("flowGoal required when using managed TaskFlow run mode");
}
return {
controllerId,
goal,
...(currentStep ? { currentStep } : {}),
...(waitingStep ? { waitingStep } : {}),
...(stateJson !== undefined ? { stateJson } : {}),
};
}
function parseResumeFlowParams(params: Record<string, unknown>): ManagedFlowResumeParams | null {
const flowId = readOptionalTrimmedString(params.flowId, "flowId");
const expectedRevision = readOptionalNumber(params.flowExpectedRevision, "flowExpectedRevision");
const currentStep = readOptionalTrimmedString(params.flowCurrentStep, "flowCurrentStep");
const waitingStep = readOptionalTrimmedString(params.flowWaitingStep, "flowWaitingStep");
const token = readOptionalTrimmedString(params.token, "token");
const approvalId = readOptionalTrimmedString(params.approvalId, "approvalId");
const approve = readOptionalBoolean(params.approve, "approve");
const runControllerId = readOptionalTrimmedString(params.flowControllerId, "flowControllerId");
const runGoal = readOptionalTrimmedString(params.flowGoal, "flowGoal");
const stateJson = params.flowStateJson;
const hasResumeFields =
flowId !== undefined ||
expectedRevision !== undefined ||
currentStep !== undefined ||
waitingStep !== undefined;
if (!hasResumeFields) {
return null;
}
if (runControllerId !== undefined || runGoal !== undefined || stateJson !== undefined) {
throw new Error("resume action does not accept flowControllerId, flowGoal, or flowStateJson");
}
if (!flowId) {
throw new Error("flowId required when using managed TaskFlow resume mode");
}
if (expectedRevision === undefined) {
throw new Error("flowExpectedRevision required when using managed TaskFlow resume mode");
}
if (!token && !approvalId) {
throw new Error("token or approvalId required when using managed TaskFlow resume mode");
}
if (approve === undefined) {
throw new Error("approve required when using managed TaskFlow resume mode");
}
return {
flowId,
expectedRevision,
...(currentStep ? { currentStep } : {}),
...(waitingStep ? { waitingStep } : {}),
};
}
function formatManagedFlowResult(result: ManagedFlowSuccessResult) {
const envelope =
result.envelope && typeof result.envelope === "object" && !Array.isArray(result.envelope)
? result.envelope
: { envelope: result.envelope };
const details = {
...envelope,
flow: result.flow,
mutation: result.mutation,
};
return jsonResult(details);
}
function requireTaskFlowRuntime(taskFlow: BoundTaskFlow | undefined, action: "run" | "resume") {
if (!taskFlow) {
throw new Error(`Managed TaskFlow ${action} mode requires a bound taskFlow runtime`);
}
return taskFlow;
}
function resolveManagedFlowToolResult(result: ManagedLobsterFlowResult) {
if (!result.ok) {
throw result.error;
}
return formatManagedFlowResult(result);
}
export function createLobsterTool(api: OpenClawPluginApi, options?: LobsterToolOptions) {
const runner = options?.runner ?? createEmbeddedLobsterRunner();
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()),
approvalId: 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: optionalPositiveIntegerSchema(),
maxStdoutBytes: optionalPositiveIntegerSchema(),
flowControllerId: Type.Optional(Type.String()),
flowGoal: Type.Optional(Type.String()),
flowStateJson: Type.Optional(Type.String()),
flowId: Type.Optional(Type.String()),
flowExpectedRevision: optionalNonNegativeIntegerSchema(),
flowCurrentStep: Type.Optional(Type.String()),
flowWaitingStep: Type.Optional(Type.String()),
}),
async execute(_id: string, params: Record<string, unknown>) {
const action = typeof params.action === "string" ? params.action.trim() : "";
if (!action) {
throw new Error("action required");
}
if (action !== "run" && action !== "resume") {
throw new Error(`Unknown action: ${action}`);
}
const cwd = resolveLobsterCwd(params.cwd);
const timeoutMs = readPositiveIntegerParam(params, "timeoutMs") ?? 20_000;
const maxStdoutBytes = readPositiveIntegerParam(params, "maxStdoutBytes") ?? 512_000;
if (api.runtime?.version && api.logger?.debug) {
api.logger.debug(`lobster plugin runtime=${api.runtime.version}`);
}
const runnerParams: LobsterRunnerParams = {
action,
...(typeof params.pipeline === "string" ? { pipeline: params.pipeline } : {}),
...(typeof params.argsJson === "string" ? { argsJson: params.argsJson } : {}),
...(typeof params.token === "string" ? { token: params.token } : {}),
...(typeof params.approvalId === "string" ? { approvalId: params.approvalId } : {}),
...(typeof params.approve === "boolean" ? { approve: params.approve } : {}),
cwd,
timeoutMs,
maxStdoutBytes,
};
const taskFlow = options?.taskFlow;
if (action === "run") {
const flowParams = parseRunFlowParams(params);
if (flowParams) {
return resolveManagedFlowToolResult(
await runManagedLobsterFlow({
taskFlow: requireTaskFlowRuntime(taskFlow, "run"),
runner,
runnerParams,
controllerId: flowParams.controllerId,
goal: flowParams.goal,
...(flowParams.stateJson !== undefined ? { stateJson: flowParams.stateJson } : {}),
...(flowParams.currentStep ? { currentStep: flowParams.currentStep } : {}),
...(flowParams.waitingStep ? { waitingStep: flowParams.waitingStep } : {}),
}),
);
}
} else {
const flowParams = parseResumeFlowParams(params);
if (flowParams) {
return resolveManagedFlowToolResult(
await resumeManagedLobsterFlow({
taskFlow: requireTaskFlowRuntime(taskFlow, "resume"),
runner,
runnerParams: runnerParams as LobsterRunnerParams & {
action: "resume";
approve: boolean;
} & ({ token: string } | { approvalId: string }),
flowId: flowParams.flowId,
expectedRevision: flowParams.expectedRevision,
...(flowParams.currentStep ? { currentStep: flowParams.currentStep } : {}),
...(flowParams.waitingStep ? { waitingStep: flowParams.waitingStep } : {}),
}),
);
}
}
const envelope = await runner.run(runnerParams);
if (!envelope.ok) {
throw new Error(envelope.error.message);
}
return jsonResult(envelope);
},
};
}

View File

@@ -0,0 +1,51 @@
// Lobster helper module supports taskflow test helpers behavior.
import { vi } from "vitest";
import type { OpenClawPluginApi } from "../runtime-api.js";
type BoundTaskFlow = ReturnType<
NonNullable<OpenClawPluginApi["runtime"]>["tasks"]["managedFlows"]["bindSession"]
>;
export function createFakeTaskFlow(overrides?: Partial<BoundTaskFlow>): BoundTaskFlow {
const baseFlow = {
flowId: "flow-1",
revision: 1,
syncMode: "managed" as const,
controllerId: "tests/lobster",
ownerKey: "agent:main:main",
status: "running" as const,
goal: "Run Lobster workflow",
};
const createManaged = vi.fn().mockReturnValue(baseFlow);
return {
sessionKey: "agent:main:main",
createManaged,
tryCreateManaged: vi.fn((params) => createManaged(params)),
get: vi.fn(),
list: vi.fn().mockReturnValue([]),
findLatest: vi.fn(),
resolve: vi.fn(),
getTaskSummary: vi.fn(),
setWaiting: vi.fn().mockImplementation((input) => ({
applied: true,
flow: { ...baseFlow, revision: input.expectedRevision + 1, status: "waiting" as const },
})),
resume: vi.fn().mockImplementation((input) => ({
applied: true,
flow: { ...baseFlow, revision: input.expectedRevision + 1, status: "running" as const },
})),
finish: vi.fn().mockImplementation((input) => ({
applied: true,
flow: { ...baseFlow, revision: input.expectedRevision + 1, status: "completed" as const },
})),
fail: vi.fn().mockImplementation((input) => ({
applied: true,
flow: { ...baseFlow, revision: input.expectedRevision + 1, status: "failed" as const },
})),
requestCancel: vi.fn(),
cancel: vi.fn(),
runTask: vi.fn(),
...overrides,
};
}

View File

@@ -0,0 +1,16 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}