Files
fusion/plugins/fusion-plugin-openclaw-runtime/src/pi-module.ts
Fusion 836ca6807a feat(FN-3717): add OpenClaw MCP bridge (+5 more)
Commits merged:
- docs(FN-3717): update openclaw runtime and settings docs
- test(FN-3717): fix openclaw engine test typing
- test(FN-3717): strengthen openclaw bridge coverage
- feat(FN-3717): complete Step 2 — wire engine and bundle delivery
- fix(FN-3717): address openclaw bridge review feedback
- feat(FN-3717): complete Step 1 — add OpenClaw MCP bridge

Files changed:
.changeset/fn-3717-openclaw-tool-bridge.md         |  5 ++
 docs/settings-reference.md                         | 28 +++++---
 packages/cli/src/__tests__/bundle-output.test.ts   |  9 +++
 packages/cli/tsup.config.ts                        |  7 ++
 .../src/__tests__/openclaw-runtime-e2e.test.ts     | 28 ++++++++
 .../__tests__/openclaw-runtime-integration.test.ts | 10 +++
 plugins/fusion-plugin-openclaw-runtime/README.md   | 13 ++++
 .../src/__tests__/cli-spawn.test.ts                | 82 +++++++++++++++++++---
 .../src/__tests__/mcp-config.test.ts               | 45 ++++++++++++
 .../src/__tests__/runtime-adapter.test.ts          | 46 ++++++++++++
 .../fusion-plugin-openclaw-runtime/src/index.ts    |  5 ++
 .../src/mcp-config.ts                              | 60 ++++++++++++++++
 .../src/mcp-schema-server.cjs                      | 59 ++++++++++++++++
 .../src/pi-module.ts                               | 52 ++++++++++++--
 .../src/runtime-adapter.ts                         | 26 +++++++
 .../fusion-plugin-openclaw-runtime/src/types.ts    |  4 ++
 16 files changed, 457 insertions(+), 22 deletions(-)

Fusion-Task-Id: FN-3717
2026-05-07 18:00:18 -07:00

373 lines
11 KiB
TypeScript

/**
* OpenClaw CLI spawn module.
*
* Drives the local `openclaw` binary via `openclaw --no-color agent --local --json`
* and parses the resulting JSON document on stdout. No daemon required.
*
* (Filename kept as `pi-module.ts` for compatibility with imports — the tests,
* runtime-adapter, and dashboard probe façade all import it under this name.)
*/
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import type {
CliConfig,
GatewayCallbacks,
GatewaySession,
OpenClawAgentJson,
} from "./types.js";
// ---------------------------------------------------------------------------
// Defaults & helpers
// ---------------------------------------------------------------------------
const DEFAULT_BINARY = "openclaw";
const DEFAULT_AGENT_ID = "main";
const DEFAULT_THINKING = "off";
const DEFAULT_TIMEOUT_SEC = 0; // 0 = no openclaw-side timeout
const DEFAULT_CLI_TIMEOUT_MS = 300_000; // 5 min hard kill on our side
// eslint-disable-next-line no-control-regex -- ANSI escapes are control chars by definition
const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g;
function asString(v: unknown): string | undefined {
return typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
}
function asNumber(v: unknown): number | undefined {
if (typeof v === "number" && Number.isFinite(v)) return v;
const s = asString(v);
if (s === undefined) return undefined;
const n = Number(s);
return Number.isFinite(n) ? n : undefined;
}
function asBool(v: unknown): boolean | undefined {
if (typeof v === "boolean") return v;
const s = asString(v);
if (s === undefined) return undefined;
return s === "1" || s.toLowerCase() === "true";
}
function stripAnsi(text: string): string {
return text.replace(ANSI_RE, "");
}
// ---------------------------------------------------------------------------
// resolveCliConfig — settings + env-var resolution
// ---------------------------------------------------------------------------
export function resolveCliConfig(settings?: Record<string, unknown>): CliConfig {
return {
binaryPath:
asString(settings?.binaryPath) ??
asString(process.env.OPENCLAW_BIN) ??
DEFAULT_BINARY,
agentId:
asString(settings?.agentId) ??
asString(process.env.OPENCLAW_AGENT_ID) ??
DEFAULT_AGENT_ID,
model:
asString(settings?.model) ??
asString(process.env.OPENCLAW_MODEL),
thinking:
asString(settings?.thinking) ??
asString(process.env.OPENCLAW_THINKING) ??
DEFAULT_THINKING,
cliTimeoutSec:
asNumber(settings?.cliTimeoutSec) ??
asNumber(process.env.OPENCLAW_TIMEOUT_SEC) ??
DEFAULT_TIMEOUT_SEC,
cliTimeoutMs:
asNumber(settings?.cliTimeoutMs) ??
asNumber(process.env.OPENCLAW_CLI_TIMEOUT_MS) ??
DEFAULT_CLI_TIMEOUT_MS,
useGateway:
asBool(settings?.useGateway) ??
asBool(process.env.OPENCLAW_USE_GATEWAY) ??
false,
};
}
// ---------------------------------------------------------------------------
// buildOpenClawArgs — argv builder
// ---------------------------------------------------------------------------
/**
* Build the argv for a single openclaw agent invocation.
*
* `--no-color` is a TOP-LEVEL flag and must come BEFORE `agent`.
* `--local` is appended unless `useGateway` is true.
*/
export function buildOpenClawArgs(
config: CliConfig,
session: Pick<GatewaySession, "sessionId" | "mcpProfile">,
message: string,
): string[] {
const args: string[] = ["--no-color"];
if (session.mcpProfile) {
args.push("--profile", session.mcpProfile);
}
args.push("agent");
if (!config.useGateway) args.push("--local");
args.push("--json");
args.push("--session-id", session.sessionId);
args.push("--message", message);
args.push("--agent", config.agentId);
if (config.model) {
args.push("--model", config.model);
}
args.push("--thinking", config.thinking);
args.push("--timeout", String(config.cliTimeoutSec));
return args;
}
// ---------------------------------------------------------------------------
// extractStderrError — last meaningful line, ANSI-stripped
// ---------------------------------------------------------------------------
export function extractStderrError(stderr: string, stdout?: string): string {
const tryExtract = (raw: string): string | undefined => {
if (!raw) return undefined;
const lines = stripAnsi(raw)
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
return lines.length > 0 ? lines[lines.length - 1] : undefined;
};
return (
tryExtract(stderr) ??
tryExtract(stdout ?? "") ??
"openclaw exited with non-zero status (no stderr)"
);
}
// ---------------------------------------------------------------------------
// createCliSession — mints a UUID + transcript bookkeeping
// ---------------------------------------------------------------------------
export function createCliSession(opts: {
systemPrompt: string;
agentId?: string;
callbacks?: GatewayCallbacks;
mcpProfile?: string;
mcpConfigPath?: string;
}): GatewaySession {
return {
sessionId: randomUUID(),
agentId: opts.agentId ?? DEFAULT_AGENT_ID,
systemPrompt: opts.systemPrompt,
messages: [{ role: "developer", content: opts.systemPrompt }],
lastModelDescription: `openclaw/${opts.agentId ?? DEFAULT_AGENT_ID}`,
lastUsage: undefined,
callbacks: opts.callbacks,
mcpProfile: opts.mcpProfile,
mcpConfigPath: opts.mcpConfigPath,
};
}
// ---------------------------------------------------------------------------
// configureOpenClawMcpServer — configure profile-scoped MCP server via CLI
// ---------------------------------------------------------------------------
export async function configureOpenClawMcpServer(opts: {
binaryPath: string;
profile: string;
serverName: string;
serverConfigPath: string;
}): Promise<void> {
const serverValue = await readFile(opts.serverConfigPath, "utf-8");
await new Promise<void>((resolve, reject) => {
const child = spawn(opts.binaryPath, ["--no-color", "--profile", opts.profile, "mcp", "set", opts.serverName, serverValue], {
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf-8");
});
child.on("error", (err) => reject(new Error(`openclaw: failed to configure MCP server — ${err.message}`)));
child.on("close", (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`openclaw: mcp set failed (${String(code)}): ${extractStderrError(stderr)}`));
});
});
}
// ---------------------------------------------------------------------------
// promptCli — spawns openclaw, parses the JSON, fires callbacks
// ---------------------------------------------------------------------------
export async function promptCli(
session: GatewaySession,
message: string,
config: CliConfig,
callbacks?: GatewayCallbacks,
signal?: AbortSignal,
): Promise<void> {
const args = buildOpenClawArgs(config, session, message);
const cb: GatewayCallbacks = { ...session.callbacks, ...callbacks };
cb.onToolStart?.("openclaw.agent", { sessionId: session.sessionId });
return new Promise<void>((resolve, reject) => {
let settled = false;
const child = spawn(config.binaryPath, args, {
stdio: ["ignore", "pipe", "pipe"],
});
const hardKill = setTimeout(() => {
if (settled) return;
settled = true;
try {
child.kill("SIGKILL");
} catch {
// already gone
}
reject(
new Error(
`openclaw: process timed out after ${config.cliTimeoutMs}ms`,
),
);
}, config.cliTimeoutMs);
const onAbort = (): void => {
if (settled) return;
try {
child.kill("SIGTERM");
} catch {
// already gone
}
};
if (signal) {
if (signal.aborted) {
onAbort();
} else {
signal.addEventListener("abort", onAbort, { once: true });
}
}
let stdout = "";
let stderr = "";
child.stdout?.on("data", (chunk: Buffer) => {
stdout += chunk.toString("utf-8");
});
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf-8");
});
child.on("error", (err: NodeJS.ErrnoException) => {
if (settled) return;
settled = true;
clearTimeout(hardKill);
signal?.removeEventListener("abort", onAbort);
const isNotFound = err.code === "ENOENT";
reject(
new Error(
isNotFound
? `openclaw: binary not found at "${config.binaryPath}". Install OpenClaw (npm i -g openclaw) or set binaryPath/OPENCLAW_BIN.`
: `openclaw: spawn error — ${err.message}`,
),
);
});
child.on("close", (code: number | null) => {
if (settled) return;
settled = true;
clearTimeout(hardKill);
signal?.removeEventListener("abort", onAbort);
if (code !== 0) {
const err =
stderr.trim() || stdout.trim()
? extractStderrError(stderr, stdout)
: `openclaw: exited with code ${String(code)}`;
reject(new Error(err));
return;
}
// Exit 0 — parse JSON
let parsed: OpenClawAgentJson;
try {
parsed = JSON.parse(stdout) as OpenClawAgentJson;
} catch {
reject(new Error(`openclaw: failed to parse JSON output (stdout=${stdout.slice(0, 200)})`));
return;
}
const payloads = parsed.payloads ?? [];
const visibleText = payloads
.filter((p) => !p.isError && !p.isReasoning)
.map((p) => p.text ?? "")
.filter((t) => t.length > 0)
.join("");
const reasoningText = payloads
.filter((p) => p.isReasoning)
.map((p) => p.text ?? "")
.filter((t) => t.length > 0)
.join("\n");
const errorText = payloads
.filter((p) => p.isError)
.map((p) => p.text ?? "")
.filter((t) => t.length > 0);
const finalText = visibleText || parsed.meta?.finalAssistantVisibleText || "";
if (finalText) cb.onText?.(finalText);
if (reasoningText) cb.onThinking?.(reasoningText);
// Update transcript + session metadata
session.messages.push({ role: "user", content: message });
if (finalText) {
session.messages.push({ role: "assistant", content: finalText });
}
const agentMeta = parsed.meta?.agentMeta;
if (agentMeta?.usage) session.lastUsage = agentMeta.usage;
if (agentMeta?.provider && agentMeta.model) {
session.lastModelDescription = `openclaw/${session.agentId}/${agentMeta.provider}/${agentMeta.model}`;
}
const metaError = parsed.meta?.error;
const isError = !!metaError;
cb.onToolEnd?.(
"openclaw.agent",
isError,
{
usage: agentMeta?.usage,
provider: agentMeta?.provider,
model: agentMeta?.model,
...(metaError ? { error: metaError } : {}),
...(errorText.length > 0 ? { toolErrors: errorText } : {}),
},
);
resolve();
});
});
}
// ---------------------------------------------------------------------------
// describeCliModel — for runtime-adapter's describeModel()
// ---------------------------------------------------------------------------
export function describeCliModel(session: GatewaySession): string {
return session.lastModelDescription || `openclaw/${session.agentId}`;
}