Merge upstream/main (1547 commits) into local main

Resolved 7 conflicts and reconciled local patches with upstream's evolved API:

- packages/core/src/plugin-loader.ts: dropped local createContextFor()/buildContext()
  in favor of upstream's createRouteContext() which provides the same TaskStore-
  override capability our patch added.

- packages/dashboard/src/routes.ts: removed local manual plugin-route mounter
  (upstream's createPluginRouter handles this with richer response support);
  kept registerPluginExemptPath loop so external webhook plugins (telemetry-
  watcher, Grafana/Sentry) still bypass daemon-token auth. Removed obsolete
  resolveHeartbeatMonitorFor (replaced by upstream's resolveHeartbeatMonitor).

- packages/dashboard/src/routes/register-agent-runtime-routes.ts: ported
  4 call sites to upstream's isHeartbeatMonitorForProject + resolveHeartbeatMonitor
  dual-fallback pattern (multi-project monitor resolution).

- packages/cli/package.json: combined upstream's pi-ai 0.73.0 + dockerode
  bumps with our cross-spawn dep for vendored pi-claude-cli.

- pnpm-workspace.yaml: kept telemetry-watcher entry alongside upstream's new
  plugin entries (droid-runtime, cursor-runtime, agent-browser, whatsapp-chat,
  roadmap, even-realities-glasses, even-cards, reports).

- packages/dashboard/app/components/SetupWizardModal.css: kept z-index:110
  fix (wizard stacks above ModelOnboardingModal) and added upstream's
  overflow-y/overscroll-behavior on .setup-wizard-overlay.

- pnpm-lock.yaml: took upstream verbatim; pnpm install regenerated to
  include cross-spawn.

Typechecks: @fusion/core and @fusion/dashboard pass cleanly.
This commit is contained in:
semih
2026-05-11 07:56:13 +00:00
1930 changed files with 349766 additions and 83450 deletions

View File

@@ -1,5 +1,282 @@
# @fusion-plugin-examples/openclaw-runtime
## 0.2.34
### Patch Changes
- @fusion/plugin-sdk@0.26.0
## 0.2.33
### Patch Changes
- @fusion/plugin-sdk@0.25.0
## 0.2.32
### Patch Changes
- @fusion/plugin-sdk@0.24.0
## 0.2.31
### Patch Changes
- @fusion/plugin-sdk@0.23.0
## 0.2.30
### Patch Changes
- @fusion/plugin-sdk@0.22.0
## 0.2.29
### Patch Changes
- @fusion/plugin-sdk@0.21.0
## 0.2.28
### Patch Changes
- @fusion/plugin-sdk@0.20.0
## 0.2.27
### Patch Changes
- @fusion/plugin-sdk@0.19.0
## 0.2.26
### Patch Changes
- @fusion/plugin-sdk@0.18.1
## 0.2.25
### Patch Changes
- @fusion/plugin-sdk@0.18.0
## 0.2.24
### Patch Changes
- @fusion/plugin-sdk@0.17.2
## 0.2.23
### Patch Changes
- @fusion/plugin-sdk@0.17.1
## 0.2.22
### Patch Changes
- @fusion/plugin-sdk@0.17.0
## 0.2.21
### Patch Changes
- @fusion/plugin-sdk@0.16.0
## 0.2.20
### Patch Changes
- @fusion/plugin-sdk@0.15.0
## 0.2.19
### Patch Changes
- @fusion/plugin-sdk@0.14.3
## 0.2.18
### Patch Changes
- @fusion/plugin-sdk@0.14.2
## 0.2.17
### Patch Changes
- @fusion/plugin-sdk@0.14.1
## 0.2.16
### Patch Changes
- @fusion/plugin-sdk@0.14.0
## 0.2.15
### Patch Changes
- @fusion/plugin-sdk@0.13.0
## 0.2.14
### Patch Changes
- @fusion/plugin-sdk@0.12.0
## 0.2.13
### Patch Changes
- @fusion/plugin-sdk@0.11.0
## 0.2.12
### Patch Changes
- @fusion/plugin-sdk@0.10.0
## 0.2.11
### Patch Changes
- @fusion/plugin-sdk@0.9.4
## 0.2.10
### Patch Changes
- @fusion/plugin-sdk@0.9.3
## 0.2.9
### Patch Changes
- @fusion/plugin-sdk@0.10.0
## 0.2.8
### Patch Changes
- Updated dependencies [76deb48]
- Updated dependencies [f6242c2]
- @fusion/plugin-sdk@0.9.1
## 0.2.7
### Patch Changes
- Updated dependencies [48208db]
- Updated dependencies [a654795]
- Updated dependencies [a654795]
- Updated dependencies [a654795]
- Updated dependencies [a654795]
- Updated dependencies [a654795]
- Updated dependencies [91f9f20]
- Updated dependencies [b91533c]
- Updated dependencies [7f42c7f]
- Updated dependencies [17a072c]
- Updated dependencies [1beebc0]
- Updated dependencies [a654795]
- Updated dependencies [bb5402a]
- Updated dependencies [a654795]
- Updated dependencies [a654795]
- @fusion/plugin-sdk@0.9.0
## 0.2.6
### Patch Changes
- @fusion/plugin-sdk@0.8.4
## 0.2.5
### Patch Changes
- @fusion/plugin-sdk@0.8.3
## 0.2.4
### Patch Changes
- @fusion/plugin-sdk@0.8.2
## 0.2.3
### Patch Changes
- @fusion/plugin-sdk@0.8.1
## 0.2.2
### Patch Changes
- @fusion/plugin-sdk@0.8.0
## 0.2.1
### Patch Changes
- ce6dcef: fix(0.7.1): mobile polish, modal layout fixes, paperclip CLI parity, schema migration
Mobile / dashboard:
- ModelOnboardingModal: dialog was off-screen on phones because the desktop `min-width: 640px` won over the mobile `max-width: 100%`. Reset min-width/min-height to 0 in the mobile media query (with `!important` so persisted desktop sizes from `useModalResizePersist` cannot re-pin it). Compact provider cards: keep the icon inline beside the name + description, shrink the icon container, drop name/description font sizes, and rely on flex-wrap so the API-key actions still drop to their own row underneath. The API-key input + Save button now live on a single row at the full card width — input grows left-aligned, Save shrinks to the right with a hairline of inline padding.
- NewAgentDialog: the dialog's top was rendering hidden behind the in-page Agents header on mobile. Render the dialog through `createPortal(..., document.body)` so the overlay escapes the `.agents-view` stacking context. Mobile media query also drops the overlay padding, fills 100vw / 100dvh with safe-area insets on header/footer for iOS notch + home indicator, and fixes the classic flex `min-height: auto` bug that prevented `overflow-y: auto` on the body from activating.
- TerminalModal: same root cause as the onboarding modal — desktop `min-width: 480px` / `min-height: 320px` pinned the modal off-screen on phones. Reset to 0 in the mobile rule with `!important` so persisted desktop sizes can't override.
- WorkflowStepManager: fix React error #310 ("Rendered more hooks than during the previous render") that prevented the workflow steps panel from loading. `useOverlayDismiss` was being called after an `if (!isOpen) return null` early return, so the hook count differed between open/closed renders. Moved the hook above the early return.
- SettingsModal auth panel: tightened `.auth-panel-body` horizontal padding from `--space-xl` (24px) to `--space-md` (12px), giving each provider card more horizontal room.
Paperclip runtime:
- CLI parity: in the dashboard's "Local CLI" tab, Test / fetch companies / fetch agents now actually shell out to `paperclipai` instead of making HTTP calls through a derived URL. New CLI-backed variants (`probePaperclipViaCli`, `listCompaniesViaCli`, `listCompanyAgentsViaCli`, `createIssueViaCli`, `getIssueViaCli`, `agentsMeViaCli`) drive every Paperclip call that has a CLI counterpart; the runtime adapter routes through them when `transport=cli`. `getIssueComments` / `wakeAgent` / `getRunEvents` continue using HTTP (no matching `paperclipai` subcommands) but rely on the apiKey discovered from the local paperclipai config so CLI mode works end-to-end.
- New dashboard routes `/providers/paperclip/cli-status`, `/cli-companies`, `/cli-agents` exposing the CLI helpers.
Plugin runtime registry:
- `GET /api/plugins/runtimes` now merges a bundled hermes/openclaw/paperclip fallback list on top of installed plugins, so the NewAgentDialog "Plugin Runtime" dropdown populates without requiring `fn plugin install` on a fresh setup. Installed plugins override the bundled entry by `runtimeId`. Coalesced the optional `version` field to `"0.0.0"` to satisfy the bundled-runtime type.
Core:
- Schema migration fix: bumped `SCHEMA_VERSION` from 48 → 49 so migration 49 (per-task `nodeId` column for remote-node routing) actually runs. Existing DBs at version 48 hit the early-return guard, never created the column, and `TaskStore.listTasks` crashed at startup with `no such column: nodeId` — the dashboard exited before initialization. The bump unblocks app startup on any pre-existing 0.7.0 install.
- Updated dependencies [ce6dcef]
- @fusion/plugin-sdk@0.7.1
## 0.2.0
### Minor Changes
- b30e017: feat(runtimes): real Hermes / OpenClaw / Paperclip runtime plugins
Replaces the stub runtime plugins with end-to-end working integrations:
- **Hermes** runtime drives the local `hermes` CLI as a subprocess (`hermes chat -q ... -Q --source tool [--resume <id>]`), captures session ids for continuity, with profile picker (HERMES_HOME-based switching) and Nous Research co-brand.
- **OpenClaw** runtime drives `openclaw --no-color agent --local --json --session-id <uuid> --message <prompt>`, parses the OpenAI-compatible JSON output, surfaces visible/reasoning text via callbacks; defaults to embedded mode (no daemon required).
- **Paperclip** runtime now uses the modern `POST /api/agents/{id}/wakeup` + heartbeat-run streaming API (replaces the old issue-checkout + heartbeat-invoke flow); supports both API mode (URL + bearer) and CLI mode (auto-derives URL from `~/.paperclip/instances/default/config.json`); company + agent dropdowns; CLI key bootstrap via `paperclipai agent local-cli`.
Engine fix: `agent-session-helpers.ts:createResolvedAgentSession` now attaches the resolved runtime's `promptWithFallback` to the session so pi's dispatch hook routes prompts through the plugin runtime instead of falling through to pi's native path.
Dashboard adds a unified `RuntimeCardShell` component, real provider logos (caduceus, pixel-lobster, paperclip outline), Test/Save/Save & Test buttons with success/failure toasts, "Learn more →" links, and a "Runtimes" group in Settings.
Backend adds `GET /providers/{hermes,openclaw,paperclip}/status`, `GET /providers/hermes/profiles`, `GET /providers/paperclip/{companies,agents,cli-discovery}`, `POST /providers/paperclip/cli-mint-key`.
Plugin SDK: now ships a proper `dist/` build (was previously TS-source-only), unblocking runtime imports from compiled plugins.
### Patch Changes
- Updated dependencies [b30e017]
- @fusion/plugin-sdk@0.7.0
## 0.1.5
### Patch Changes
- @fusion/engine@0.6.0
- @fusion/plugin-sdk@0.6.0
## 0.1.4
### Patch Changes

View File

@@ -1,43 +1,90 @@
# OpenClaw Runtime Plugin
Provides an executable OpenClaw runtime plugin for Fusion. This package enables runtime registration, discovery, and session execution so agents configured with `runtimeConfig.runtimeHint: "openclaw"` can run through the standard runtime adapter contract.
Drives the local **`openclaw` CLI** ([openclaw/openclaw](https://github.com/openclaw/openclaw)) as a subprocess. By default it runs `openclaw agent --local` (embedded mode, no daemon required); you can opt into the WebSocket gateway with `useGateway: true`.
## Overview
## What it does
This plugin follows the runtime adapter pattern used by other executable plugin runtimes:
For each `promptWithFallback(session, prompt)`:
- Registers OpenClaw runtime metadata for resolver discovery
- Creates executable runtime sessions via `createFnAgent`
- Delegates prompt execution through `promptWithFallback`
- Exposes model descriptions through `describeModel`
- Supports best-effort session disposal via `dispose()`
1. Spawns `openclaw --no-color agent --local --json --session-id <uuid> --message <prompt>` (plus `--agent`, `--model`, `--thinking`, `--timeout` if configured).
2. Reads the single JSON document on stdout (matching `OpenClawAgentJson`):
- Concatenates `payloads[]` where `!isError && !isReasoning``session.callbacks.onText(...)`.
- Joins `payloads[].isReasoning === true` `session.callbacks.onThinking(...)`.
- Surfaces tool-level errors (`payloads[].isError === true`) as a logger warning.
- Stores `meta.agentMeta.usage` on the session for token accounting.
3. Reuses the same UUID across every prompt for the session so OpenClaw resumes the same agent conversation server-side.
## Installation
The previous HTTP `/v1/chat/completions` integration has been removed — that endpoint required a separate gateway daemon and was an OpenAI-compat shim. The CLI surface is the canonical OpenClaw API.
### Option 1: Copy to plugins directory
## Fusion tool-control (MCP bridge)
When a Fusion OpenClaw session includes custom tools, the runtime plugin now enables tool-control through OpenClaw's supported MCP configuration flow:
1. Collect session tools and filter out built-ins: `read`, `write`, `edit`, `bash`, `grep`, `find`.
2. Convert remaining tools into MCP-compatible schemas.
3. Write a temporary schema file and MCP server config (`node mcp-schema-server.cjs <schema.json>`).
4. Configure a profile-scoped MCP server using `openclaw --profile <id> mcp set fusion-custom-tools <json>`.
5. Spawn the agent turn with `openclaw --profile <id> agent ...` so OpenClaw can see the configured MCP server.
No private protocol is used — this is the verified OpenClaw CLI contract (`mcp set` + `--profile`).
## Prerequisites
```bash
cp -r fusion-plugin-openclaw-runtime ~/.fusion/plugins/
npm install -g openclaw
```
### Option 2: Install via CLI
Verify with `openclaw --version` (expect `OpenClaw 2026.x.y`).
```bash
fn plugin install ./plugins/fusion-plugin-openclaw-runtime
If you want gateway mode (`useGateway: true`), also start `openclaw gateway run` separately.
> **First-run note:** the very first `openclaw agent` invocation lazy-installs runtime deps and can take 3060s. Subsequent calls are fast.
## Settings
| Key | Env var | Default | Notes |
|---|---|---|---|
| `binaryPath` | `OPENCLAW_BIN` | `openclaw` | Path to the `openclaw` binary. |
| `agentId` | `OPENCLAW_AGENT_ID` | `main` | Maps to `--agent <id>`. List with `openclaw agents list`. |
| `model` | `OPENCLAW_MODEL` | (OpenClaw default) | Maps to `--model <provider/model>`, e.g. `anthropic/claude-haiku-4-5`. |
| `thinking` | `OPENCLAW_THINKING` | `off` | One of `off | minimal | low | medium | high | xhigh | adaptive | max`. |
| `cliTimeoutSec` | `OPENCLAW_TIMEOUT_SEC` | `0` | OpenClaw-side timeout (0 = no limit). |
| `cliTimeoutMs` | `OPENCLAW_CLI_TIMEOUT_MS` | `300000` | Hard kill on the Fusion side. |
| `useGateway` | `OPENCLAW_USE_GATEWAY` | `false` | When `true`, omit `--local`; the CLI tries the WS gateway and falls back to embedded after ~2 s. |
Settings precedence: plugin settings → env var → default.
## Limitations
- **No per-token streaming.** `--json` emits a single JSON document at process exit. `onText` is called exactly once.
- **Default ignores the gateway.** With `useGateway: false` (default) we always pass `--local`, skipping the WebSocket connect attempt entirely. Most users want this.
- **Built-in tools are intentionally excluded from MCP bridge.** `read`, `write`, `edit`, `bash`, `grep`, and `find` stay native to Fusion and are not duplicated through OpenClaw MCP.
- **AbortSignal sends SIGTERM.** If the CLI ignores it (e.g. during a long model download), the hard-kill timer (`cliTimeoutMs`) eventually fires.
## Public API
```ts
import {
OpenClawRuntimeAdapter,
resolveCliConfig,
buildOpenClawArgs,
createCliSession,
promptCli,
describeCliModel,
extractStderrError,
probeOpenClawBinary,
type CliConfig,
type GatewaySession,
type OpenClawAgentJson,
type OpenClawBinaryStatus,
} from "@fusion-plugin-examples/openclaw-runtime";
```
## Runtime Metadata
`probeOpenClawBinary({ binaryPath?, timeoutMs? })` runs `openclaw --version` and returns `{ available, version, binaryPath, reason, probeDurationMs }` — used by the dashboard's "Runtimes → OpenClaw" settings card.
- **Plugin ID:** `fusion-plugin-openclaw-runtime`
- **Package name:** `@fusion-plugin-examples/openclaw-runtime`
- **Runtime ID:** `openclaw`
- **Runtime name:** `OpenClaw Runtime`
- **Version:** `0.1.0`
- **Description:** OpenClaw-backed AI session using the user's configured pi provider and model
## Agent configuration
## Agent Configuration
Configure an agent to target OpenClaw via `runtimeConfig.runtimeHint`:
To create a Fusion agent backed by OpenClaw, set `runtimeConfig.runtimeHint`:
```json
{
@@ -49,12 +96,17 @@ Configure an agent to target OpenClaw via `runtimeConfig.runtimeHint`:
}
```
## Local Development
Runtime selection happens in the dashboard's **New Agent → Plugin Runtime → OpenClaw**.
## Metadata
- **Plugin ID:** `fusion-plugin-openclaw-runtime`
- **Runtime ID:** `openclaw`
- **Package:** `@fusion-plugin-examples/openclaw-runtime`
## Development
```bash
# Run plugin tests
pnpm --filter @fusion-plugin-examples/openclaw-runtime test
# Build plugin output to dist/
pnpm --filter @fusion-plugin-examples/openclaw-runtime test # 44 tests
pnpm --filter @fusion-plugin-examples/openclaw-runtime build
```

View File

@@ -1,94 +1,112 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const { mockCreateFnAgent, mockPromptWithFallback, mockDescribeModel } = vi.hoisted(() => ({
mockCreateFnAgent: vi.fn(),
mockPromptWithFallback: vi.fn(),
mockDescribeModel: vi.fn().mockReturnValue("unknown model"),
const { mockResolveCliConfig, mockProbeBinary } = vi.hoisted(() => ({
mockResolveCliConfig: vi.fn().mockReturnValue({
binaryPath: "openclaw",
agentId: "main",
model: undefined,
thinking: "off",
cliTimeoutSec: 0,
cliTimeoutMs: 300_000,
useGateway: false,
}),
mockProbeBinary: vi.fn().mockResolvedValue({
available: true,
binaryPath: "/opt/homebrew/bin/openclaw",
version: "OpenClaw 2026.4.26",
probeDurationMs: 12,
}),
}));
vi.mock("../pi-module.js", () => ({
createFnAgent: mockCreateFnAgent,
promptWithFallback: mockPromptWithFallback,
describeModel: mockDescribeModel,
}));
import plugin, { openclawRuntimeMetadata, openclawRuntimeFactory, OPENCLAW_RUNTIME_ID } from "../index.js";
vi.mock("../pi-module.js", async () => {
const actual = await vi.importActual("../pi-module.js");
return {
...actual,
resolveCliConfig: mockResolveCliConfig,
};
});
vi.mock("../probe.js", async () => {
const actual = await vi.importActual("../probe.js");
return {
...actual,
probeOpenClawBinary: mockProbeBinary,
};
});
import plugin, { openclawRuntimeMetadata, openclawRuntimeFactory, OPENCLAW_RUNTIME_ID, } from "../index.js";
import { OpenClawRuntimeAdapter } from "../runtime-adapter.js";
function createMockContext(overrides = {}) {
function createMockContext(settings = {}) {
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
return {
pluginId: "fusion-plugin-openclaw-runtime",
settings: {},
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
settings,
logger,
emitEvent: vi.fn(),
taskStore: {
getTask: vi.fn(),
},
...overrides,
taskStore: { getTask: vi.fn() },
};
}
describe("openclaw-runtime plugin", () => {
beforeEach(() => {
vi.clearAllMocks();
mockResolveCliConfig.mockReturnValue({
binaryPath: "openclaw",
agentId: "main",
model: undefined,
thinking: "off",
cliTimeoutSec: 0,
cliTimeoutMs: 300_000,
useGateway: false,
});
mockProbeBinary.mockResolvedValue({
available: true,
binaryPath: "/opt/homebrew/bin/openclaw",
version: "OpenClaw 2026.4.26",
probeDurationMs: 12,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("plugin manifest identity", () => {
it("should have correct manifest fields", () => {
expect(plugin.manifest.id).toBe("fusion-plugin-openclaw-runtime");
expect(plugin.manifest.name).toBe("OpenClaw Runtime Plugin");
expect(plugin.manifest.version).toBe("0.1.0");
expect(plugin.manifest.description).toContain("OpenClaw");
expect(plugin.manifest.author).toBe("Fusion Team");
expect(plugin.state).toBe("installed");
});
it("manifest identity is stable", () => {
expect(plugin.manifest.id).toBe("fusion-plugin-openclaw-runtime");
expect(plugin.manifest.name).toBe("OpenClaw Runtime Plugin");
expect(plugin.state).toBe("installed");
expect(plugin.runtime?.metadata.runtimeId).toBe(OPENCLAW_RUNTIME_ID);
expect(plugin.manifest.runtime).toEqual(openclawRuntimeMetadata);
});
describe("runtime registration", () => {
it("should register openclaw runtime metadata", () => {
expect(plugin.runtime).toBeDefined();
expect(plugin.runtime?.metadata.runtimeId).toBe(OPENCLAW_RUNTIME_ID);
expect(plugin.runtime?.metadata.name).toBe("OpenClaw Runtime");
expect(plugin.runtime?.metadata.description).toContain("OpenClaw-backed AI session");
expect(plugin.runtime?.metadata.version).toBe("0.1.0");
});
it("should have consistent runtime metadata between export and manifest", () => {
expect(plugin.manifest.runtime).toEqual(openclawRuntimeMetadata);
expect(plugin.runtime?.metadata).toEqual(openclawRuntimeMetadata);
});
it("onLoad probes binary and logs binary path + version", async () => {
const ctx = createMockContext({});
await plugin.hooks.onLoad(ctx);
expect(mockProbeBinary).toHaveBeenCalledWith({ binaryPath: "openclaw" });
expect(ctx.logger.info).toHaveBeenCalledWith(expect.stringContaining("openclaw"));
expect(ctx.emitEvent).toHaveBeenCalledWith("openclaw-runtime:loaded", expect.objectContaining({
runtimeId: OPENCLAW_RUNTIME_ID,
binaryAvailable: true,
}));
});
describe("hooks", () => {
it("onLoad should log startup message and emit loaded event", async () => {
const ctx = createMockContext();
await plugin.hooks.onLoad?.(ctx);
expect(ctx.logger.info).toHaveBeenCalledWith("OpenClaw Runtime Plugin loaded");
expect(ctx.emitEvent).toHaveBeenCalledWith("openclaw-runtime:loaded", {
runtimeId: OPENCLAW_RUNTIME_ID,
version: "0.1.0",
});
});
it("onUnload should not throw", () => {
expect(() => plugin.hooks.onUnload?.()).not.toThrow();
it("onLoad logs warning when binary missing", async () => {
mockProbeBinary.mockResolvedValueOnce({
available: false,
probeDurationMs: 5,
reason: "`openclaw` not found on PATH",
});
const ctx = createMockContext({});
await plugin.hooks.onLoad(ctx);
expect(ctx.logger.info).toHaveBeenCalledWith(expect.stringContaining("not detected"));
});
describe("runtime factory behavior", () => {
it("should export runtime constants", () => {
expect(OPENCLAW_RUNTIME_ID).toBe("openclaw");
expect(openclawRuntimeMetadata.runtimeId).toBe("openclaw");
expect(typeof openclawRuntimeFactory).toBe("function");
});
it("runtime factory should return executable runtime adapter", async () => {
const runtime = (await openclawRuntimeFactory(createMockContext()));
expect(runtime).toBeInstanceOf(OpenClawRuntimeAdapter);
expect(runtime.id).toBe("openclaw");
expect(runtime.name).toBe("OpenClaw Runtime");
expect(runtime).not.toHaveProperty("status");
expect(runtime).not.toHaveProperty("execute");
});
it("factory creation should not throw", async () => {
await expect(openclawRuntimeFactory(createMockContext())).resolves.toBeInstanceOf(OpenClawRuntimeAdapter);
});
it("factory returns an OpenClawRuntimeAdapter instance", async () => {
const runtime = (await openclawRuntimeFactory(createMockContext({ binaryPath: "/usr/bin/openclaw", agentId: "ops" })));
expect(runtime).toBeInstanceOf(OpenClawRuntimeAdapter);
expect(runtime.id).toBe("openclaw");
});
it("factory creation does not throw with empty settings", async () => {
await expect(openclawRuntimeFactory(createMockContext())).resolves.toBeInstanceOf(OpenClawRuntimeAdapter);
});
it("onUnload does not throw", () => {
const ctx = createMockContext();
expect(() => plugin.hooks.onUnload?.(ctx)).not.toThrow();
});
});
//# sourceMappingURL=index.test.js.map

View File

@@ -1 +1 @@
{"version":3,"file":"index.test.js","sourceRoot":"","sources":["../../src/__tests__/index.test.ts"],"names":[],"mappings":"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"}
{"version":3,"file":"index.test.js","sourceRoot":"","sources":["../../src/__tests__/index.test.ts"],"names":[],"mappings":"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"}

View File

@@ -1,8 +1,8 @@
/**
* OpenClaw Runtime Plugin
*
* Provides an executable OpenClaw runtime adapter for Fusion's plugin runtime
* discovery and session execution pipeline.
* Drives the local `openclaw` CLI as a subprocess (via
* `openclaw --no-color agent --local --json`). No daemon required.
*/
import type { FusionPlugin, PluginRuntimeFactory, PluginRuntimeManifestMetadata } from "@fusion/plugin-sdk";
declare const OPENCLAW_RUNTIME_ID = "openclaw";
@@ -11,4 +11,10 @@ declare const openclawRuntimeFactory: PluginRuntimeFactory;
declare const plugin: FusionPlugin;
export default plugin;
export { openclawRuntimeMetadata, openclawRuntimeFactory, OPENCLAW_RUNTIME_ID };
export { OpenClawRuntimeAdapter } from "./runtime-adapter.js";
export { resolveCliConfig, buildOpenClawArgs, createCliSession, promptCli, describeCliModel, extractStderrError, configureOpenClawMcpServer, } from "./pi-module.js";
export type { CliConfig, GatewaySession, OpenClawAgentJson } from "./types.js";
export { toolsToMcpToolDefs, writeOpenClawMcpBridgeFiles, } from "./mcp-config.js";
export { probeOpenClawBinary } from "./probe.js";
export type { OpenClawBinaryStatus } from "./probe.js";
//# sourceMappingURL=index.d.ts.map

View File

@@ -1 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,OAAO,KAAK,EACV,YAAY,EACZ,oBAAoB,EACpB,6BAA6B,EAC9B,MAAM,oBAAoB,CAAC;AAE5B,QAAA,MAAM,mBAAmB,aAAa,CAAC;AAGvC,QAAA,MAAM,uBAAuB,EAAE,6BAK9B,CAAC;AAEF,QAAA,MAAM,sBAAsB,EAAE,oBAE7B,CAAC;AAEF,QAAA,MAAM,MAAM,EAAE,YA2BZ,CAAC;AAEH,eAAe,MAAM,CAAC;AAEtB,OAAO,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,mBAAmB,EAAE,CAAC"}
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAMH,OAAO,KAAK,EACV,YAAY,EAEZ,oBAAoB,EACpB,6BAA6B,EAC9B,MAAM,oBAAoB,CAAC;AAE5B,QAAA,MAAM,mBAAmB,aAAa,CAAC;AAGvC,QAAA,MAAM,uBAAuB,EAAE,6BAK9B,CAAC;AAEF,QAAA,MAAM,sBAAsB,EAAE,oBAE7B,CAAC;AAEF,QAAA,MAAM,MAAM,EAAE,YAqCZ,CAAC;AAEH,eAAe,MAAM,CAAC;AAItB,OAAO,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,mBAAmB,EAAE,CAAC;AAChF,OAAO,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AAC9D,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,EAChB,SAAS,EACT,gBAAgB,EAChB,kBAAkB,EAClB,0BAA0B,GAC3B,MAAM,gBAAgB,CAAC;AACxB,YAAY,EAAE,SAAS,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAC/E,OAAO,EACL,kBAAkB,EAClB,2BAA2B,GAC5B,MAAM,iBAAiB,CAAC;AAGzB,OAAO,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AACjD,YAAY,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC"}

View File

@@ -1,43 +1,51 @@
/**
* OpenClaw Runtime Plugin
*
* Provides an executable OpenClaw runtime adapter for Fusion's plugin runtime
* discovery and session execution pipeline.
* Drives the local `openclaw` CLI as a subprocess (via
* `openclaw --no-color agent --local --json`). No daemon required.
*/
import { definePlugin } from "@fusion/plugin-sdk";
import { OpenClawRuntimeAdapter } from "./runtime-adapter.js";
import { resolveCliConfig } from "./pi-module.js";
import { probeOpenClawBinary } from "./probe.js";
const OPENCLAW_RUNTIME_ID = "openclaw";
const OPENCLAW_RUNTIME_VERSION = "0.1.0";
const OPENCLAW_RUNTIME_VERSION = "0.2.0";
const openclawRuntimeMetadata = {
runtimeId: OPENCLAW_RUNTIME_ID,
name: "OpenClaw Runtime",
description: "OpenClaw-backed AI session using the user's configured pi provider and model",
description: "Drives the local `openclaw` CLI (openclaw/openclaw)",
version: OPENCLAW_RUNTIME_VERSION,
};
const openclawRuntimeFactory = async () => {
return new OpenClawRuntimeAdapter();
const openclawRuntimeFactory = async (ctx) => {
return new OpenClawRuntimeAdapter(ctx?.settings);
};
const plugin = definePlugin({
manifest: {
id: "fusion-plugin-openclaw-runtime",
name: "OpenClaw Runtime Plugin",
version: "0.1.0",
description: "Provides OpenClaw runtime for Fusion AI agents",
version: OPENCLAW_RUNTIME_VERSION,
description: "Drives the local `openclaw` CLI for Fusion agents — embedded `--local` mode by default; gateway optional.",
author: "Fusion Team",
homepage: "https://github.com/gsxdsm/fusion",
homepage: "https://docs.openclaw.ai/",
runtime: openclawRuntimeMetadata,
},
state: "installed",
hooks: {
onLoad: (ctx) => {
ctx.logger.info("OpenClaw Runtime Plugin loaded");
onLoad: async (ctx) => {
const config = resolveCliConfig(ctx.settings);
const probe = await probeOpenClawBinary({ binaryPath: config.binaryPath });
ctx.logger.info(probe.available
? `OpenClaw Runtime Plugin loaded — binary=${config.binaryPath}${probe.version ? ` (${probe.version})` : ""}`
: `OpenClaw Runtime Plugin loaded but binary not detected: ${probe.reason ?? "unknown"}`);
ctx.emitEvent("openclaw-runtime:loaded", {
runtimeId: OPENCLAW_RUNTIME_ID,
version: OPENCLAW_RUNTIME_VERSION,
binaryAvailable: probe.available,
binaryPath: probe.binaryPath ?? config.binaryPath,
});
},
onUnload: () => {
// No context available during unload
// No persistent state to clean up — each prompt spawns a fresh subprocess.
},
},
runtime: {
@@ -46,5 +54,11 @@ const plugin = definePlugin({
},
});
export default plugin;
// ── Public exports ────────────────────────────────────────────────────────────
export { openclawRuntimeMetadata, openclawRuntimeFactory, OPENCLAW_RUNTIME_ID };
export { OpenClawRuntimeAdapter } from "./runtime-adapter.js";
export { resolveCliConfig, buildOpenClawArgs, createCliSession, promptCli, describeCliModel, extractStderrError, configureOpenClawMcpServer, } from "./pi-module.js";
export { toolsToMcpToolDefs, writeOpenClawMcpBridgeFiles, } from "./mcp-config.js";
// Probe re-export for the dashboard's runtime-provider-probes façade.
export { probeOpenClawBinary } from "./probe.js";
//# sourceMappingURL=index.js.map

View File

@@ -1 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AAO9D,MAAM,mBAAmB,GAAG,UAAU,CAAC;AACvC,MAAM,wBAAwB,GAAG,OAAO,CAAC;AAEzC,MAAM,uBAAuB,GAAkC;IAC7D,SAAS,EAAE,mBAAmB;IAC9B,IAAI,EAAE,kBAAkB;IACxB,WAAW,EAAE,8EAA8E;IAC3F,OAAO,EAAE,wBAAwB;CAClC,CAAC;AAEF,MAAM,sBAAsB,GAAyB,KAAK,IAAI,EAAE;IAC9D,OAAO,IAAI,sBAAsB,EAAE,CAAC;AACtC,CAAC,CAAC;AAEF,MAAM,MAAM,GAAiB,YAAY,CAAC;IACxC,QAAQ,EAAE;QACR,EAAE,EAAE,gCAAgC;QACpC,IAAI,EAAE,yBAAyB;QAC/B,OAAO,EAAE,OAAO;QAChB,WAAW,EAAE,gDAAgD;QAC7D,MAAM,EAAE,aAAa;QACrB,QAAQ,EAAE,kCAAkC;QAC5C,OAAO,EAAE,uBAAuB;KACjC;IACD,KAAK,EAAE,WAAW;IAClB,KAAK,EAAE;QACL,MAAM,EAAE,CAAC,GAAG,EAAE,EAAE;YACd,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,gCAAgC,CAAC,CAAC;YAClD,GAAG,CAAC,SAAS,CAAC,yBAAyB,EAAE;gBACvC,SAAS,EAAE,mBAAmB;gBAC9B,OAAO,EAAE,wBAAwB;aAClC,CAAC,CAAC;QACL,CAAC;QACD,QAAQ,EAAE,GAAG,EAAE;YACb,qCAAqC;QACvC,CAAC;KACF;IACD,OAAO,EAAE;QACP,QAAQ,EAAE,uBAAuB;QACjC,OAAO,EAAE,sBAAsB;KAChC;CACF,CAAC,CAAC;AAEH,eAAe,MAAM,CAAC;AAEtB,OAAO,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,mBAAmB,EAAE,CAAC"}
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AAC9D,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAQjD,MAAM,mBAAmB,GAAG,UAAU,CAAC;AACvC,MAAM,wBAAwB,GAAG,OAAO,CAAC;AAEzC,MAAM,uBAAuB,GAAkC;IAC7D,SAAS,EAAE,mBAAmB;IAC9B,IAAI,EAAE,kBAAkB;IACxB,WAAW,EAAE,qDAAqD;IAClE,OAAO,EAAE,wBAAwB;CAClC,CAAC;AAEF,MAAM,sBAAsB,GAAyB,KAAK,EAAE,GAAmB,EAAE,EAAE;IACjF,OAAO,IAAI,sBAAsB,CAAC,GAAG,EAAE,QAA+C,CAAC,CAAC;AAC1F,CAAC,CAAC;AAEF,MAAM,MAAM,GAAiB,YAAY,CAAC;IACxC,QAAQ,EAAE;QACR,EAAE,EAAE,gCAAgC;QACpC,IAAI,EAAE,yBAAyB;QAC/B,OAAO,EAAE,wBAAwB;QACjC,WAAW,EACT,2GAA2G;QAC7G,MAAM,EAAE,aAAa;QACrB,QAAQ,EAAE,2BAA2B;QACrC,OAAO,EAAE,uBAAuB;KACjC;IACD,KAAK,EAAE,WAAW;IAClB,KAAK,EAAE;QACL,MAAM,EAAE,KAAK,EAAE,GAAG,EAAE,EAAE;YACpB,MAAM,MAAM,GAAG,gBAAgB,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;YAC9C,MAAM,KAAK,GAAG,MAAM,mBAAmB,CAAC,EAAE,UAAU,EAAE,MAAM,CAAC,UAAU,EAAE,CAAC,CAAC;YAE3E,GAAG,CAAC,MAAM,CAAC,IAAI,CACb,KAAK,CAAC,SAAS;gBACb,CAAC,CAAC,2CAA2C,MAAM,CAAC,UAAU,GAAG,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,KAAK,KAAK,CAAC,OAAO,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE;gBAC7G,CAAC,CAAC,2DAA2D,KAAK,CAAC,MAAM,IAAI,SAAS,EAAE,CAC3F,CAAC;YACF,GAAG,CAAC,SAAS,CAAC,yBAAyB,EAAE;gBACvC,SAAS,EAAE,mBAAmB;gBAC9B,OAAO,EAAE,wBAAwB;gBACjC,eAAe,EAAE,KAAK,CAAC,SAAS;gBAChC,UAAU,EAAE,KAAK,CAAC,UAAU,IAAI,MAAM,CAAC,UAAU;aAClD,CAAC,CAAC;QACL,CAAC;QACD,QAAQ,EAAE,GAAG,EAAE;YACb,2EAA2E;QAC7E,CAAC;KACF;IACD,OAAO,EAAE;QACP,QAAQ,EAAE,uBAAuB;QACjC,OAAO,EAAE,sBAAsB;KAChC;CACF,CAAC,CAAC;AAEH,eAAe,MAAM,CAAC;AAEtB,iFAAiF;AAEjF,OAAO,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,mBAAmB,EAAE,CAAC;AAChF,OAAO,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AAC9D,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,EAChB,SAAS,EACT,gBAAgB,EAChB,kBAAkB,EAClB,0BAA0B,GAC3B,MAAM,gBAAgB,CAAC;AAExB,OAAO,EACL,kBAAkB,EAClB,2BAA2B,GAC5B,MAAM,iBAAiB,CAAC;AAEzB,sEAAsE;AACtE,OAAO,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC"}

View File

@@ -8,7 +8,7 @@
"runtime": {
"runtimeId": "openclaw",
"name": "OpenClaw Runtime",
"description": "OpenClaw-backed AI session using the user's configured pi provider and model",
"description": "OpenClaw-backed AI session using the local OpenClaw gateway",
"version": "0.1.0"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@fusion-plugin-examples/openclaw-runtime",
"version": "0.1.4",
"version": "0.2.34",
"type": "module",
"description": "Provides OpenClaw runtime for Fusion AI agents",
"keywords": [
@@ -21,7 +21,6 @@
"test": "vitest run --silent=passed-only --reporter=dot"
},
"dependencies": {
"@fusion/engine": "workspace:*",
"@fusion/plugin-sdk": "workspace:*"
},
"devDependencies": {

View File

@@ -0,0 +1,530 @@
/**
* Tests for the CLI spawn helper (pi-module.ts).
*
* All tests mock `node:child_process.spawn` so no real subprocess is started.
*/
import { EventEmitter } from "node:events";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
buildOpenClawArgs,
createCliSession,
extractStderrError,
promptCli,
resolveCliConfig,
configureOpenClawMcpServer,
} from "../pi-module.js";
import type { CliConfig, GatewaySession } from "../types.js";
// ---------------------------------------------------------------------------
// Child process mock factory
// ---------------------------------------------------------------------------
interface FakeStreams {
stdout: EventEmitter & { on: (event: string, cb: (chunk: Buffer) => void) => FakeStreams["stdout"] };
stderr: EventEmitter & { on: (event: string, cb: (chunk: Buffer) => void) => FakeStreams["stderr"] };
}
interface FakeChild extends EventEmitter {
stdout: FakeStreams["stdout"];
stderr: FakeStreams["stderr"];
kill: ReturnType<typeof vi.fn>;
}
function makeFakeChild(): FakeChild {
const child = new EventEmitter() as FakeChild;
child.stdout = new EventEmitter() as FakeStreams["stdout"];
child.stderr = new EventEmitter() as FakeStreams["stderr"];
child.kill = vi.fn();
return child;
}
function makeSuccessJson(opts: {
text?: string;
reasoning?: string;
errorText?: string;
provider?: string;
model?: string;
usage?: Record<string, number>;
metaError?: { kind: string; message: string };
} = {}) {
return JSON.stringify({
payloads: [
...(opts.reasoning ? [{ text: opts.reasoning, isReasoning: true }] : []),
...(opts.text ? [{ text: opts.text }] : []),
...(opts.errorText ? [{ text: opts.errorText, isError: true }] : []),
],
meta: {
durationMs: 123,
...(opts.metaError ? { error: opts.metaError } : {}),
agentMeta: {
sessionId: "s1",
provider: opts.provider ?? "anthropic",
model: opts.model ?? "claude-opus-4-5",
usage: opts.usage ?? { input: 10, output: 20, total: 30 },
},
},
});
}
// ---------------------------------------------------------------------------
// Helpers to drive the fake process lifecycle
// ---------------------------------------------------------------------------
function emitSuccess(child: FakeChild, jsonStr: string): void {
child.stdout.emit("data", Buffer.from(jsonStr));
child.emit("close", 0);
}
function emitFailure(child: FakeChild, code: number, stderrMsg: string): void {
child.stderr.emit("data", Buffer.from(stderrMsg));
child.emit("close", code);
}
// ---------------------------------------------------------------------------
// Vitest module mock
// ---------------------------------------------------------------------------
const spawnMock = vi.fn();
vi.mock("node:child_process", () => ({
spawn: (...args: unknown[]) => spawnMock(...args),
}));
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("resolveCliConfig", () => {
const originalEnv = { ...process.env };
beforeEach(() => {
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = { ...originalEnv };
});
it("uses hardcoded defaults when no settings or env vars are set", () => {
delete process.env.OPENCLAW_BIN;
delete process.env.OPENCLAW_AGENT_ID;
delete process.env.OPENCLAW_MODEL;
delete process.env.OPENCLAW_THINKING;
delete process.env.OPENCLAW_TIMEOUT_SEC;
delete process.env.OPENCLAW_CLI_TIMEOUT_MS;
delete process.env.OPENCLAW_USE_GATEWAY;
const cfg = resolveCliConfig();
expect(cfg.binaryPath).toBe("openclaw");
expect(cfg.agentId).toBe("main");
expect(cfg.model).toBeUndefined();
expect(cfg.thinking).toBe("off");
expect(cfg.cliTimeoutSec).toBe(0);
expect(cfg.cliTimeoutMs).toBe(300_000);
expect(cfg.useGateway).toBe(false);
});
it("settings override env vars", () => {
process.env.OPENCLAW_BIN = "/usr/bin/openclaw";
process.env.OPENCLAW_AGENT_ID = "env-agent";
const cfg = resolveCliConfig({
binaryPath: "/opt/homebrew/bin/openclaw",
agentId: "settings-agent",
});
expect(cfg.binaryPath).toBe("/opt/homebrew/bin/openclaw");
expect(cfg.agentId).toBe("settings-agent");
});
it("env vars override defaults", () => {
process.env.OPENCLAW_BIN = "/usr/local/bin/openclaw";
process.env.OPENCLAW_AGENT_ID = "env-main";
process.env.OPENCLAW_MODEL = "openai/gpt-4o";
process.env.OPENCLAW_THINKING = "high";
process.env.OPENCLAW_TIMEOUT_SEC = "120";
process.env.OPENCLAW_CLI_TIMEOUT_MS = "60000";
process.env.OPENCLAW_USE_GATEWAY = "true";
const cfg = resolveCliConfig();
expect(cfg.binaryPath).toBe("/usr/local/bin/openclaw");
expect(cfg.agentId).toBe("env-main");
expect(cfg.model).toBe("openai/gpt-4o");
expect(cfg.thinking).toBe("high");
expect(cfg.cliTimeoutSec).toBe(120);
expect(cfg.cliTimeoutMs).toBe(60_000);
expect(cfg.useGateway).toBe(true);
});
});
describe("buildOpenClawArgs", () => {
const baseConfig: CliConfig = {
binaryPath: "openclaw",
agentId: "main",
model: undefined,
thinking: "off",
cliTimeoutSec: 0,
cliTimeoutMs: 300_000,
useGateway: false,
};
it("puts --no-color first, then agent subcommand", () => {
const args = buildOpenClawArgs(baseConfig, { sessionId: "uuid-1" }, "hello");
expect(args[0]).toBe("--no-color");
expect(args[1]).toBe("agent");
});
it("includes --local when useGateway is false", () => {
const args = buildOpenClawArgs(baseConfig, { sessionId: "uuid-1" }, "hello");
expect(args).toContain("--local");
});
it("omits --local when useGateway is true", () => {
const args = buildOpenClawArgs({ ...baseConfig, useGateway: true }, { sessionId: "uuid-1" }, "hello");
expect(args).not.toContain("--local");
});
it("includes --profile before agent when MCP profile is configured", () => {
const args = buildOpenClawArgs(baseConfig, { sessionId: "my-uuid", mcpProfile: "fusion-profile" }, "test prompt");
expect(args[0]).toBe("--no-color");
expect(args).toContain("--profile");
expect(args[args.indexOf("--profile") + 1]).toBe("fusion-profile");
expect(args.indexOf("--profile")).toBeLessThan(args.indexOf("agent"));
});
it("omits --profile when no MCP profile is configured", () => {
const args = buildOpenClawArgs(baseConfig, { sessionId: "my-uuid" }, "test prompt");
expect(args).not.toContain("--profile");
});
it("includes --json, --session-id, --message, --agent", () => {
const args = buildOpenClawArgs(baseConfig, { sessionId: "my-uuid" }, "test prompt");
expect(args).toContain("--json");
expect(args).toContain("--session-id");
expect(args[args.indexOf("--session-id") + 1]).toBe("my-uuid");
expect(args).toContain("--message");
expect(args[args.indexOf("--message") + 1]).toBe("test prompt");
expect(args).toContain("--agent");
expect(args[args.indexOf("--agent") + 1]).toBe("main");
});
it("includes --model when configured", () => {
const args = buildOpenClawArgs({ ...baseConfig, model: "anthropic/claude-opus-4-5" }, { sessionId: "u" }, "p");
expect(args).toContain("--model");
expect(args[args.indexOf("--model") + 1]).toBe("anthropic/claude-opus-4-5");
});
it("omits --model when not configured", () => {
const args = buildOpenClawArgs(baseConfig, { sessionId: "u" }, "p");
expect(args).not.toContain("--model");
});
it("includes --thinking with the configured level", () => {
const args = buildOpenClawArgs({ ...baseConfig, thinking: "high" }, { sessionId: "u" }, "p");
expect(args).toContain("--thinking");
expect(args[args.indexOf("--thinking") + 1]).toBe("high");
});
it("includes --timeout with cliTimeoutSec", () => {
const args = buildOpenClawArgs({ ...baseConfig, cliTimeoutSec: 120 }, { sessionId: "u" }, "p");
expect(args).toContain("--timeout");
expect(args[args.indexOf("--timeout") + 1]).toBe("120");
});
});
describe("extractStderrError", () => {
it("returns the last non-empty stripped line", () => {
const msg = extractStderrError("\x1b[31mError:\x1b[0m something failed\nfinal line\n\n");
expect(msg).toBe("final line");
});
it("strips ANSI codes", () => {
const msg = extractStderrError("\x1b[1;33mWarning\x1b[0m: bad thing");
expect(msg).toBe("Warning: bad thing");
});
it("falls back to stdout when stderr is empty", () => {
const msg = extractStderrError("", "stdout fallback");
expect(msg).toBe("stdout fallback");
});
it("returns sentinel when both are empty", () => {
const msg = extractStderrError("");
expect(msg).toContain("non-zero");
});
});
describe("configureOpenClawMcpServer", () => {
it("spawns `openclaw mcp set` with profile", async () => {
const child = makeFakeChild();
spawnMock.mockImplementation(() => {
setTimeout(() => child.emit("close", 0), 0);
return child;
});
const fs = await import("node:fs/promises");
const path = await import("node:path");
const os = await import("node:os");
const filePath = path.join(os.tmpdir(), `openclaw-mcp-config-${Date.now()}.json`);
await fs.writeFile(filePath, JSON.stringify({ command: "node", args: ["server.cjs", "schema.json"] }));
await configureOpenClawMcpServer({
binaryPath: "openclaw",
profile: "fusion-profile",
serverName: "fusion-custom-tools",
serverConfigPath: filePath,
});
const [, args] = spawnMock.mock.calls[0] as [string, string[]];
expect(args).toEqual(expect.arrayContaining(["--profile", "fusion-profile", "mcp", "set", "fusion-custom-tools"]));
});
it("throws when mcp set exits non-zero", async () => {
const child = makeFakeChild();
spawnMock.mockImplementation(() => {
setTimeout(() => {
child.stderr.emit("data", Buffer.from("bad config"));
child.emit("close", 1);
}, 0);
return child;
});
const fs = await import("node:fs/promises");
const path = await import("node:path");
const os = await import("node:os");
const filePath = path.join(os.tmpdir(), `openclaw-mcp-config-${Date.now()}-err.json`);
await fs.writeFile(filePath, JSON.stringify({ command: "node", args: ["server.cjs", "schema.json"] }));
await expect(
configureOpenClawMcpServer({
binaryPath: "openclaw",
profile: "fusion-profile",
serverName: "fusion-custom-tools",
serverConfigPath: filePath,
}),
).rejects.toThrow(/mcp set failed/);
});
});
describe("promptCli", () => {
beforeEach(() => {
vi.clearAllMocks();
});
function defaultConfig(overrides: Partial<CliConfig> = {}): CliConfig {
return {
binaryPath: "openclaw",
agentId: "main",
model: undefined,
thinking: "off",
cliTimeoutSec: 0,
cliTimeoutMs: 300_000,
useGateway: false,
...overrides,
};
}
function makeSession(): GatewaySession {
return createCliSession({ systemPrompt: "System" });
}
it("calls spawn with --no-color as first arg", async () => {
const child = makeFakeChild();
spawnMock.mockReturnValue(child);
const session = makeSession();
const run = promptCli(session, "hi", defaultConfig());
emitSuccess(child, makeSuccessJson({ text: "hello" }));
await run;
const [, args] = spawnMock.mock.calls[0] as [string, string[]];
expect(args[0]).toBe("--no-color");
expect(args[1]).toBe("agent");
});
it("passes --session-id to spawn", async () => {
const child = makeFakeChild();
spawnMock.mockReturnValue(child);
const session = makeSession();
const initialId = session.sessionId;
const run = promptCli(session, "hi", defaultConfig());
emitSuccess(child, makeSuccessJson({ text: "reply" }));
await run;
const [, args] = spawnMock.mock.calls[0] as [string, string[]];
expect(args[args.indexOf("--session-id") + 1]).toBe(initialId);
});
it("reuses the same session id across multiple calls", async () => {
const session = makeSession();
const sessionId = session.sessionId;
for (let i = 0; i < 2; i++) {
const child = makeFakeChild();
spawnMock.mockReturnValue(child);
const run = promptCli(session, `prompt ${i}`, defaultConfig());
emitSuccess(child, makeSuccessJson({ text: `reply ${i}` }));
await run;
}
const ids = (spawnMock.mock.calls as [string, string[]][]).map(
([, args]) => args[args.indexOf("--session-id") + 1],
);
expect(ids[0]).toBe(sessionId);
expect(ids[1]).toBe(sessionId);
});
it("calls onText with concatenated non-error non-reasoning payloads", async () => {
const child = makeFakeChild();
spawnMock.mockReturnValue(child);
const onText = vi.fn();
const session = makeSession();
const run = promptCli(session, "hi", defaultConfig(), { onText });
emitSuccess(child, makeSuccessJson({ text: "visible answer" }));
await run;
expect(onText).toHaveBeenCalledOnce();
expect(onText).toHaveBeenCalledWith("visible answer");
});
it("calls onThinking with reasoning payloads joined by newline", async () => {
const child = makeFakeChild();
spawnMock.mockReturnValue(child);
const onThinking = vi.fn();
const session = makeSession();
const run = promptCli(session, "hi", defaultConfig(), { onThinking });
emitSuccess(child, makeSuccessJson({ text: "answer", reasoning: "I think therefore I am" }));
await run;
expect(onThinking).toHaveBeenCalledWith("I think therefore I am");
});
it("fires onToolStart and onToolEnd for openclaw.agent", async () => {
const child = makeFakeChild();
spawnMock.mockReturnValue(child);
const onToolStart = vi.fn();
const onToolEnd = vi.fn();
const session = makeSession();
const run = promptCli(session, "hi", defaultConfig(), { onToolStart, onToolEnd });
emitSuccess(child, makeSuccessJson({ text: "ok" }));
await run;
expect(onToolStart).toHaveBeenCalledWith("openclaw.agent", expect.objectContaining({ sessionId: session.sessionId }));
expect(onToolEnd).toHaveBeenCalledWith("openclaw.agent", false, expect.objectContaining({ usage: expect.any(Object) }));
});
it("sets isError=true on onToolEnd when meta.error is present", async () => {
const child = makeFakeChild();
spawnMock.mockReturnValue(child);
const onToolEnd = vi.fn();
const session = makeSession();
const run = promptCli(session, "hi", defaultConfig(), { onToolEnd });
emitSuccess(
child,
makeSuccessJson({ text: "partial", metaError: { kind: "timeout", message: "timed out" } }),
);
await run;
expect(onToolEnd).toHaveBeenCalledWith(
"openclaw.agent",
true,
expect.objectContaining({ error: { kind: "timeout", message: "timed out" } }),
);
});
it("throws a clean error message when exit code is non-zero", async () => {
const child = makeFakeChild();
spawnMock.mockReturnValue(child);
const session = makeSession();
const run = promptCli(session, "hi", defaultConfig());
emitFailure(child, 1, "\x1b[31mError:\x1b[0m agent crashed\n");
await expect(run).rejects.toThrow("agent crashed");
});
it("throws when exit non-zero and stderr has no content", async () => {
const child = makeFakeChild();
spawnMock.mockReturnValue(child);
const session = makeSession();
const run = promptCli(session, "hi", defaultConfig());
child.emit("close", 2);
await expect(run).rejects.toThrow(/exited with code 2/);
});
it("throws when stdout is not valid JSON (exit 0)", async () => {
const child = makeFakeChild();
spawnMock.mockReturnValue(child);
const session = makeSession();
const run = promptCli(session, "hi", defaultConfig());
child.stdout.emit("data", Buffer.from("not-json"));
child.emit("close", 0);
await expect(run).rejects.toThrow(/failed to parse JSON output/);
});
it("sends SIGTERM when AbortSignal is aborted", async () => {
const child = makeFakeChild();
spawnMock.mockReturnValue(child);
const ac = new AbortController();
const session = makeSession();
// Do NOT await — we abort mid-flight
const run = promptCli(session, "hi", defaultConfig(), {}, ac.signal);
ac.abort();
// After abort the child should have been killed; let the close propagate
// with a non-zero code so we can verify kill was called
child.kill.mockImplementation(() => {
child.emit("close", 130);
});
// Trigger the abort handler again after mock is set
ac.signal.dispatchEvent(new Event("abort"));
child.emit("close", 130);
await expect(run).rejects.toThrow();
// kill was called at least once (the second dispatchEvent call)
// — this is the important invariant
});
it("uses --local by default and omits it with useGateway=true", async () => {
// Default (useGateway=false)
{
const child = makeFakeChild();
spawnMock.mockReturnValue(child);
const session = makeSession();
const run = promptCli(session, "hi", defaultConfig({ useGateway: false }));
emitSuccess(child, makeSuccessJson({ text: "ok" }));
await run;
const [, args] = spawnMock.mock.calls[spawnMock.mock.calls.length - 1] as [string, string[]];
expect(args).toContain("--local");
}
vi.clearAllMocks();
// useGateway=true
{
const child = makeFakeChild();
spawnMock.mockReturnValue(child);
const session = makeSession();
const run = promptCli(session, "hi", defaultConfig({ useGateway: true }));
emitSuccess(child, makeSuccessJson({ text: "ok" }));
await run;
const [, args] = spawnMock.mock.calls[spawnMock.mock.calls.length - 1] as [string, string[]];
expect(args).not.toContain("--local");
}
});
});

View File

@@ -1,18 +1,48 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const { mockCreateFnAgent, mockPromptWithFallback, mockDescribeModel } = vi.hoisted(() => ({
mockCreateFnAgent: vi.fn(),
mockPromptWithFallback: vi.fn(),
mockDescribeModel: vi.fn().mockReturnValue("unknown model"),
const { mockResolveCliConfig, mockProbeBinary } = vi.hoisted(() => ({
mockResolveCliConfig: vi.fn().mockReturnValue({
binaryPath: "openclaw",
agentId: "main",
model: undefined,
thinking: "off",
cliTimeoutSec: 0,
cliTimeoutMs: 300_000,
useGateway: false,
}),
mockProbeBinary: vi.fn().mockResolvedValue({
available: true,
binaryPath: "/opt/homebrew/bin/openclaw",
version: "OpenClaw 2026.4.26",
probeDurationMs: 12,
}),
}));
vi.mock("../pi-module.js", () => ({
createFnAgent: mockCreateFnAgent,
promptWithFallback: mockPromptWithFallback,
describeModel: mockDescribeModel,
}));
vi.mock("../pi-module.js", async () => {
const actual = await vi.importActual<typeof import("../pi-module.js")>(
"../pi-module.js",
);
return {
...actual,
resolveCliConfig: mockResolveCliConfig,
};
});
import plugin, { openclawRuntimeMetadata, openclawRuntimeFactory, OPENCLAW_RUNTIME_ID } from "../index.js";
vi.mock("../probe.js", async () => {
const actual = await vi.importActual<typeof import("../probe.js")>(
"../probe.js",
);
return {
...actual,
probeOpenClawBinary: mockProbeBinary,
};
});
import plugin, {
openclawRuntimeMetadata,
openclawRuntimeFactory,
OPENCLAW_RUNTIME_ID,
} from "../index.js";
import { OpenClawRuntimeAdapter } from "../runtime-adapter.js";
interface MockLogger {
@@ -22,107 +52,99 @@ interface MockLogger {
debug: ReturnType<typeof vi.fn>;
}
interface MockContext {
pluginId: string;
settings: Record<string, unknown>;
logger: MockLogger;
emitEvent: ReturnType<typeof vi.fn>;
taskStore: {
getTask: ReturnType<typeof vi.fn>;
function createMockContext(settings: Record<string, unknown> = {}) {
const logger: MockLogger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
}
function createMockContext(overrides: Partial<MockContext> = {}): MockContext {
return {
pluginId: "fusion-plugin-openclaw-runtime",
settings: {},
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
settings,
logger,
emitEvent: vi.fn(),
taskStore: {
getTask: vi.fn(),
},
...overrides,
taskStore: { getTask: vi.fn() },
};
}
describe("openclaw-runtime plugin", () => {
beforeEach(() => {
vi.clearAllMocks();
mockResolveCliConfig.mockReturnValue({
binaryPath: "openclaw",
agentId: "main",
model: undefined,
thinking: "off",
cliTimeoutSec: 0,
cliTimeoutMs: 300_000,
useGateway: false,
});
mockProbeBinary.mockResolvedValue({
available: true,
binaryPath: "/opt/homebrew/bin/openclaw",
version: "OpenClaw 2026.4.26",
probeDurationMs: 12,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("plugin manifest identity", () => {
it("should have correct manifest fields", () => {
expect(plugin.manifest.id).toBe("fusion-plugin-openclaw-runtime");
expect(plugin.manifest.name).toBe("OpenClaw Runtime Plugin");
expect(plugin.manifest.version).toBe("0.1.0");
expect(plugin.manifest.description).toContain("OpenClaw");
expect(plugin.manifest.author).toBe("Fusion Team");
expect(plugin.state).toBe("installed");
});
it("manifest identity is stable", () => {
expect(plugin.manifest.id).toBe("fusion-plugin-openclaw-runtime");
expect(plugin.manifest.name).toBe("OpenClaw Runtime Plugin");
expect(plugin.state).toBe("installed");
expect(plugin.runtime?.metadata.runtimeId).toBe(OPENCLAW_RUNTIME_ID);
expect(plugin.manifest.runtime).toEqual(openclawRuntimeMetadata);
});
describe("runtime registration", () => {
it("should register openclaw runtime metadata", () => {
expect(plugin.runtime).toBeDefined();
expect(plugin.runtime?.metadata.runtimeId).toBe(OPENCLAW_RUNTIME_ID);
expect(plugin.runtime?.metadata.name).toBe("OpenClaw Runtime");
expect(plugin.runtime?.metadata.description).toContain("OpenClaw-backed AI session");
expect(plugin.runtime?.metadata.version).toBe("0.1.0");
});
it("should have consistent runtime metadata between export and manifest", () => {
expect(plugin.manifest.runtime).toEqual(openclawRuntimeMetadata);
expect(plugin.runtime?.metadata).toEqual(openclawRuntimeMetadata);
});
});
describe("hooks", () => {
it("onLoad should log startup message and emit loaded event", async () => {
const ctx = createMockContext();
await plugin.hooks.onLoad?.(ctx as any);
expect(ctx.logger.info).toHaveBeenCalledWith("OpenClaw Runtime Plugin loaded");
expect(ctx.emitEvent).toHaveBeenCalledWith("openclaw-runtime:loaded", {
it("onLoad probes binary and logs binary path + version", async () => {
const ctx = createMockContext({});
await plugin.hooks!.onLoad!(ctx as any);
expect(mockProbeBinary).toHaveBeenCalledWith({ binaryPath: "openclaw" });
expect(ctx.logger.info).toHaveBeenCalledWith(
expect.stringContaining("openclaw"),
);
expect(ctx.emitEvent).toHaveBeenCalledWith(
"openclaw-runtime:loaded",
expect.objectContaining({
runtimeId: OPENCLAW_RUNTIME_ID,
version: "0.1.0",
});
});
it("onUnload should not throw", () => {
expect(() => plugin.hooks.onUnload?.()).not.toThrow();
});
binaryAvailable: true,
}),
);
});
describe("runtime factory behavior", () => {
it("should export runtime constants", () => {
expect(OPENCLAW_RUNTIME_ID).toBe("openclaw");
expect(openclawRuntimeMetadata.runtimeId).toBe("openclaw");
expect(typeof openclawRuntimeFactory).toBe("function");
it("onLoad logs warning when binary missing", async () => {
mockProbeBinary.mockResolvedValueOnce({
available: false,
probeDurationMs: 5,
reason: "`openclaw` not found on PATH",
});
const ctx = createMockContext({});
await plugin.hooks!.onLoad!(ctx as any);
expect(ctx.logger.info).toHaveBeenCalledWith(
expect.stringContaining("not detected"),
);
});
it("runtime factory should return executable runtime adapter", async () => {
const runtime = (await openclawRuntimeFactory(createMockContext() as any)) as OpenClawRuntimeAdapter;
it("factory returns an OpenClawRuntimeAdapter instance", async () => {
const runtime = (await openclawRuntimeFactory(
createMockContext({ binaryPath: "/usr/bin/openclaw", agentId: "ops" }) as any,
)) as OpenClawRuntimeAdapter;
expect(runtime).toBeInstanceOf(OpenClawRuntimeAdapter);
expect(runtime.id).toBe("openclaw");
});
expect(runtime).toBeInstanceOf(OpenClawRuntimeAdapter);
expect(runtime.id).toBe("openclaw");
expect(runtime.name).toBe("OpenClaw Runtime");
expect(runtime).not.toHaveProperty("status");
expect(runtime).not.toHaveProperty("execute");
});
it("factory creation does not throw with empty settings", async () => {
await expect(
openclawRuntimeFactory(createMockContext() as any),
).resolves.toBeInstanceOf(OpenClawRuntimeAdapter);
});
it("factory creation should not throw", async () => {
await expect(openclawRuntimeFactory(createMockContext() as any)).resolves.toBeInstanceOf(
OpenClawRuntimeAdapter,
);
});
it("onUnload does not throw", () => {
const ctx = createMockContext() as any;
expect(() => plugin.hooks!.onUnload?.(ctx)).not.toThrow();
});
});

View File

@@ -0,0 +1,45 @@
import { readFileSync, unlinkSync } from "node:fs";
import { afterEach, describe, expect, it } from "vitest";
import { toolsToMcpToolDefs, writeOpenClawMcpBridgeFiles } from "../mcp-config.js";
const cleanupPaths: string[] = [];
afterEach(() => {
for (const path of cleanupPaths.splice(0)) {
try {
unlinkSync(path);
} catch {
// ignore
}
}
});
describe("toolsToMcpToolDefs", () => {
it("filters out built-in tools", () => {
const defs = toolsToMcpToolDefs([
{ name: "read", description: "builtin", parameters: { type: "object" } },
{ name: "fn_task_list", description: "list", parameters: { type: "object", properties: {} } },
]);
expect(defs).toHaveLength(1);
expect(defs[0]?.name).toBe("fn_task_list");
});
});
describe("writeOpenClawMcpBridgeFiles", () => {
it("writes schema and server config files", () => {
const bridge = writeOpenClawMcpBridgeFiles([
{ name: "fn_task_list", description: "list", inputSchema: { type: "object", properties: {} } },
], "test");
cleanupPaths.push(bridge.schemaPath, bridge.serverConfigPath);
const schema = JSON.parse(readFileSync(bridge.schemaPath, "utf-8"));
expect(Array.isArray(schema)).toBe(true);
expect(schema[0]?.name).toBe("fn_task_list");
const server = JSON.parse(readFileSync(bridge.serverConfigPath, "utf-8"));
expect(server.command).toBe("node");
expect(server.args[0]).toContain("mcp-schema-server.cjs");
expect(server.args[1]).toBe(bridge.schemaPath);
});
});

View File

@@ -0,0 +1,180 @@
/**
* Tests for the probeOpenClawBinary helper.
*
* All tests mock `node:child_process.spawn` so no real subprocess is started.
*
* Execution order within probeOpenClawBinary:
* 1. spawn(<which>, [binary]) — resolves the binary path (awaited first)
* 2. spawn(binary, ["--version"]) — actual version probe
*
* Strategy: use a call-index counter inside the mock so each spawn call
* returns a pre-built fake child. The `which` child is settled asynchronously
* via `setImmediate` so the Promise chain advances before the tests drive the
* version child.
*/
import { EventEmitter } from "node:events";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { probeOpenClawBinary } from "../probe.js";
// ---------------------------------------------------------------------------
// Child process mock
// ---------------------------------------------------------------------------
interface FakeChild extends EventEmitter {
stdout: EventEmitter;
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
}
function makeFakeChild(): FakeChild {
const c = new EventEmitter() as FakeChild;
c.stdout = new EventEmitter();
c.stderr = new EventEmitter();
c.kill = vi.fn();
return c;
}
const spawnMock = vi.fn();
vi.mock("node:child_process", () => ({
spawn: (...args: unknown[]) => spawnMock(...args),
}));
// ---------------------------------------------------------------------------
// Helper: settle the `which` child, then yield until the version probe has
// been created, then return the version child for the test to drive.
// ---------------------------------------------------------------------------
/**
* Set up the spawn mock to return pre-created fake children by call order.
* The `which` child is auto-settled inside the mock so by the time the
* probeOpenClawBinary Promise is waiting on the version spawn, it is already
* registered in spawnMock.mock.calls.
*
* Returns the version-child for callers to drive.
*/
function setupSpawnPair(whichStdout: string, whichCode: number): FakeChild {
const whichChild = makeFakeChild();
const versionChild = makeFakeChild();
let callIndex = 0;
spawnMock.mockImplementation(() => {
callIndex++;
if (callIndex === 1) {
// `which`/`where` call — auto-settle after current micro-task queue drains
setImmediate(() => {
if (whichStdout) {
whichChild.stdout.emit("data", Buffer.from(whichStdout));
}
whichChild.emit("close", whichCode);
});
return whichChild;
}
return versionChild;
});
return versionChild;
}
/** Wait for the version probe spawn to be registered (call index 2). */
async function waitForVersionSpawn(): Promise<void> {
// The `which` close fires via setImmediate; we need to give Node's event
// loop at least one full round-trip so the Promise chain inside
// tryResolveBinaryPath resolves and probeOpenClawBinary proceeds to
// spawn the version check.
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => setImmediate(resolve));
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("probeOpenClawBinary", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns available=true when the binary exits 0 with version output", async () => {
const versionChild = setupSpawnPair("/opt/homebrew/bin/openclaw\n", 0);
const probePromise = probeOpenClawBinary({ binaryPath: "openclaw", timeoutMs: 2000 });
await waitForVersionSpawn();
versionChild.stdout.emit("data", Buffer.from("OpenClaw 2026.4.26 (be8c246)\n"));
versionChild.emit("close", 0);
const result = await probePromise;
expect(result.available).toBe(true);
expect(result.version).toBe("OpenClaw 2026.4.26 (be8c246)");
expect(result.binaryPath).toBe("/opt/homebrew/bin/openclaw");
expect(result.probeDurationMs).toBeGreaterThanOrEqual(0);
});
it("returns available=false with ENOENT reason when binary is not found", async () => {
const versionChild = setupSpawnPair("", 1);
const probePromise = probeOpenClawBinary({ binaryPath: "openclaw", timeoutMs: 2000 });
await waitForVersionSpawn();
const enoentError = Object.assign(new Error("spawn openclaw ENOENT"), { code: "ENOENT" });
versionChild.emit("error", enoentError);
const result = await probePromise;
expect(result.available).toBe(false);
expect(result.reason).toContain("not found on PATH");
expect(result.probeDurationMs).toBeGreaterThanOrEqual(0);
});
it("returns available=false with the stderr reason when exit code is non-zero", async () => {
const versionChild = setupSpawnPair("", 1);
const probePromise = probeOpenClawBinary({ binaryPath: "openclaw", timeoutMs: 2000 });
await waitForVersionSpawn();
versionChild.stderr.emit("data", Buffer.from("command not recognized\n"));
versionChild.emit("close", 1);
const result = await probePromise;
expect(result.available).toBe(false);
expect(result.reason).toContain("command not recognized");
});
it("returns available=false with a timeout reason when the process hangs", async () => {
// Very short timeout; the version child never emits close
setupSpawnPair("", 1);
const result = await probeOpenClawBinary({ binaryPath: "openclaw", timeoutMs: 30 });
expect(result.available).toBe(false);
expect(result.reason).toContain("timed out");
});
it("uses the custom binaryPath when spawning the version check", async () => {
const versionChild = setupSpawnPair("", 1);
const probePromise = probeOpenClawBinary({
binaryPath: "/opt/homebrew/bin/openclaw",
timeoutMs: 2000,
});
await waitForVersionSpawn();
versionChild.stdout.emit("data", Buffer.from("OpenClaw 2026.4.26 (be8c246)\n"));
versionChild.emit("close", 0);
await probePromise;
// Second spawn call must use the custom path
const versionSpawnArgs = spawnMock.mock.calls[1] as [string, string[]];
expect(versionSpawnArgs[0]).toBe("/opt/homebrew/bin/openclaw");
expect(versionSpawnArgs[1]).toContain("--version");
});
});

View File

@@ -1,97 +1,171 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { readFile } from "node:fs/promises";
import { OpenClawRuntimeAdapter } from "../runtime-adapter.js";
const { mockCreateFnAgent, mockPromptWithFallback, mockDescribeModel } = vi.hoisted(() => ({
mockCreateFnAgent: vi.fn(),
mockPromptWithFallback: vi.fn(),
mockDescribeModel: vi.fn(),
const {
mockResolveCliConfig,
mockCreateCliSession,
mockPromptCli,
mockDescribeCliModel,
mockConfigureOpenClawMcpServer,
} = vi.hoisted(() => ({
mockResolveCliConfig: vi.fn(),
mockCreateCliSession: vi.fn(),
mockPromptCli: vi.fn(),
mockDescribeCliModel: vi.fn(),
mockConfigureOpenClawMcpServer: vi.fn(),
}));
vi.mock("../pi-module.js", () => ({
createFnAgent: mockCreateFnAgent,
promptWithFallback: mockPromptWithFallback,
describeModel: mockDescribeModel,
resolveCliConfig: mockResolveCliConfig,
createCliSession: mockCreateCliSession,
promptCli: mockPromptCli,
describeCliModel: mockDescribeCliModel,
configureOpenClawMcpServer: mockConfigureOpenClawMcpServer,
}));
describe("OpenClawRuntimeAdapter", () => {
let adapter: OpenClawRuntimeAdapter;
beforeEach(() => {
vi.clearAllMocks();
mockDescribeModel.mockReturnValue("mock/anthropic-claude");
adapter = new OpenClawRuntimeAdapter();
beforeEach(() => {
vi.clearAllMocks();
mockResolveCliConfig.mockReturnValue({
binaryPath: "openclaw",
agentId: "main",
model: undefined,
thinking: "off",
cliTimeoutSec: 0,
cliTimeoutMs: 300_000,
useGateway: false,
});
mockCreateCliSession.mockImplementation(({ systemPrompt, agentId, callbacks }) => ({
sessionId: "session-uuid-1",
agentId: agentId ?? "main",
systemPrompt,
messages: [{ role: "developer", content: systemPrompt }],
lastModelDescription: `openclaw/${agentId ?? "main"}`,
callbacks,
}));
mockDescribeCliModel.mockReturnValue("openclaw/main");
mockConfigureOpenClawMcpServer.mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("has stable runtime identity", () => {
describe("OpenClawRuntimeAdapter — identity", () => {
it("has stable id/name", () => {
const adapter = new OpenClawRuntimeAdapter();
expect(adapter.id).toBe("openclaw");
expect(adapter.name).toBe("OpenClaw Runtime");
});
it("delegates createSession to createFnAgent with mapped options", async () => {
const mockSession = { dispose: vi.fn() };
mockCreateFnAgent.mockResolvedValue({ session: mockSession, sessionFile: "/tmp/session.json" });
const result = await adapter.createSession({
cwd: "/project",
systemPrompt: "You are helpful",
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
fallbackProvider: "openai",
fallbackModelId: "gpt-4o",
skills: ["bash"],
});
expect(mockCreateFnAgent).toHaveBeenCalledWith({
cwd: "/project",
systemPrompt: "You are helpful",
tools: undefined,
customTools: undefined,
onText: undefined,
onThinking: undefined,
onToolStart: undefined,
onToolEnd: undefined,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
fallbackProvider: "openai",
fallbackModelId: "gpt-4o",
defaultThinkingLevel: undefined,
sessionManager: undefined,
skillSelection: undefined,
skills: ["bash"],
});
expect(result.session).toBe(mockSession);
expect(result.sessionFile).toBe("/tmp/session.json");
});
it("delegates promptWithFallback to pi seam", async () => {
const session = { id: "s-1" };
mockPromptWithFallback.mockResolvedValue(undefined);
await adapter.promptWithFallback(session as any, "Hello", { images: [] });
expect(mockPromptWithFallback).toHaveBeenCalledWith(session, "Hello", { images: [] });
});
it("delegates describeModel to pi seam", () => {
const session = { id: "s-2" };
mockDescribeModel.mockReturnValue("anthropic/claude-sonnet-4-5");
const result = adapter.describeModel(session as any);
expect(mockDescribeModel).toHaveBeenCalledWith(session);
expect(result).toBe("anthropic/claude-sonnet-4-5");
});
it("dispose calls session.dispose when present and no-ops otherwise", async () => {
const disposeMock = vi.fn().mockResolvedValue(undefined);
await adapter.dispose({ dispose: disposeMock });
await expect(adapter.dispose({ id: "no-dispose" } as any)).resolves.toBeUndefined();
expect(disposeMock).toHaveBeenCalledTimes(1);
it("uses the local pi-module seam and does not import @fusion/engine", async () => {
const source = await readFile(new URL("../runtime-adapter.ts", import.meta.url), "utf8");
expect(source).toContain('from "./pi-module.js"');
expect(source).not.toContain("@fusion/engine");
});
});
describe("OpenClawRuntimeAdapter — createSession", () => {
it("configures MCP bridge when custom tools are present", async () => {
const adapter = new OpenClawRuntimeAdapter({ agentId: "ops" });
await adapter.createSession({
cwd: "/repo",
systemPrompt: "You are helpful",
tools: [
{ name: "read", description: "builtin", parameters: { type: "object" } },
{ name: "fn_task_list", description: "list", parameters: { type: "object", properties: {} } },
],
});
expect(mockConfigureOpenClawMcpServer).toHaveBeenCalledOnce();
expect(mockCreateCliSession).toHaveBeenCalledWith(
expect.objectContaining({ mcpProfile: expect.stringContaining("fusion-") }),
);
});
it("configures MCP bridge when tools arrive through customTools", async () => {
const adapter = new OpenClawRuntimeAdapter({ agentId: "ops" });
await adapter.createSession({
cwd: "/repo",
systemPrompt: "You are helpful",
customTools: [{ name: "fn_task_show", description: "custom", parameters: { type: "object" } }],
});
expect(mockConfigureOpenClawMcpServer).toHaveBeenCalledOnce();
});
it("does not configure MCP bridge when only built-in tools are provided", async () => {
const adapter = new OpenClawRuntimeAdapter({ agentId: "ops" });
await adapter.createSession({
cwd: "/repo",
systemPrompt: "You are helpful",
tools: [{ name: "read", description: "builtin", parameters: { type: "object" } }],
});
expect(mockConfigureOpenClawMcpServer).not.toHaveBeenCalled();
expect(mockCreateCliSession).toHaveBeenCalledWith(
expect.objectContaining({ mcpProfile: undefined }),
);
});
it("delegates to createCliSession with systemPrompt + agentId + callbacks", async () => {
const adapter = new OpenClawRuntimeAdapter({ agentId: "ops" });
const onText = vi.fn();
const onThinking = vi.fn();
const result = await adapter.createSession({
cwd: "/repo",
systemPrompt: "You are helpful",
onText,
onThinking,
});
expect(mockCreateCliSession).toHaveBeenCalledWith(
expect.objectContaining({
systemPrompt: "You are helpful",
agentId: "main", // resolved from defaults in our mock
}),
);
expect(result.sessionFile).toBeUndefined();
expect(result.session.sessionId).toBe("session-uuid-1");
});
});
describe("OpenClawRuntimeAdapter — promptWithFallback", () => {
it("calls promptCli with session + prompt + resolved config", async () => {
const adapter = new OpenClawRuntimeAdapter();
const { session } = await adapter.createSession({
cwd: "/repo",
systemPrompt: "sys",
});
await adapter.promptWithFallback(session, "hi");
expect(mockPromptCli).toHaveBeenCalledTimes(1);
const [callSession, callPrompt, callConfig] = mockPromptCli.mock.calls[0];
expect(callSession).toBe(session);
expect(callPrompt).toBe("hi");
expect(callConfig).toMatchObject({ binaryPath: "openclaw", useGateway: false });
});
it("forwards override callbacks via the options arg", async () => {
const adapter = new OpenClawRuntimeAdapter();
const { session } = await adapter.createSession({
cwd: "/repo",
systemPrompt: "sys",
});
const onText = vi.fn();
await adapter.promptWithFallback(session, "p", { onText });
const overrideCallbacks = mockPromptCli.mock.calls[0][3];
expect(overrideCallbacks?.onText).toBe(onText);
});
});
describe("OpenClawRuntimeAdapter — describeModel/dispose", () => {
it("describeModel delegates to describeCliModel", async () => {
const adapter = new OpenClawRuntimeAdapter();
const { session } = await adapter.createSession({
cwd: "/repo",
systemPrompt: "sys",
});
expect(adapter.describeModel(session)).toBe("openclaw/main");
expect(mockDescribeCliModel).toHaveBeenCalledWith(session);
});
it("dispose is a no-op", async () => {
const adapter = new OpenClawRuntimeAdapter();
await expect(adapter.dispose!({} as any)).resolves.toBeUndefined();
});
});

View File

@@ -1,53 +1,66 @@
/**
* OpenClaw Runtime Plugin
*
* Provides an executable OpenClaw runtime adapter for Fusion's plugin runtime
* discovery and session execution pipeline.
* Drives the local `openclaw` CLI as a subprocess (via
* `openclaw --no-color agent --local --json`). No daemon required.
*/
import { definePlugin } from "@fusion/plugin-sdk";
import { OpenClawRuntimeAdapter } from "./runtime-adapter.js";
import { resolveCliConfig } from "./pi-module.js";
import { probeOpenClawBinary } from "./probe.js";
import type {
FusionPlugin,
PluginContext,
PluginRuntimeFactory,
PluginRuntimeManifestMetadata,
} from "@fusion/plugin-sdk";
const OPENCLAW_RUNTIME_ID = "openclaw";
const OPENCLAW_RUNTIME_VERSION = "0.1.0";
const OPENCLAW_RUNTIME_VERSION = "0.2.0";
const openclawRuntimeMetadata: PluginRuntimeManifestMetadata = {
runtimeId: OPENCLAW_RUNTIME_ID,
name: "OpenClaw Runtime",
description: "OpenClaw-backed AI session using the user's configured pi provider and model",
description: "Drives the local `openclaw` CLI (openclaw/openclaw)",
version: OPENCLAW_RUNTIME_VERSION,
};
const openclawRuntimeFactory: PluginRuntimeFactory = async () => {
return new OpenClawRuntimeAdapter();
const openclawRuntimeFactory: PluginRuntimeFactory = async (ctx?: PluginContext) => {
return new OpenClawRuntimeAdapter(ctx?.settings as Record<string, unknown> | undefined);
};
const plugin: FusionPlugin = definePlugin({
manifest: {
id: "fusion-plugin-openclaw-runtime",
name: "OpenClaw Runtime Plugin",
version: "0.1.0",
description: "Provides OpenClaw runtime for Fusion AI agents",
version: OPENCLAW_RUNTIME_VERSION,
description:
"Drives the local `openclaw` CLI for Fusion agents — embedded `--local` mode by default; gateway optional.",
author: "Fusion Team",
homepage: "https://github.com/gsxdsm/fusion",
homepage: "https://docs.openclaw.ai/",
runtime: openclawRuntimeMetadata,
},
state: "installed",
hooks: {
onLoad: (ctx) => {
ctx.logger.info("OpenClaw Runtime Plugin loaded");
onLoad: async (ctx) => {
const config = resolveCliConfig(ctx.settings);
const probe = await probeOpenClawBinary({ binaryPath: config.binaryPath });
ctx.logger.info(
probe.available
? `OpenClaw Runtime Plugin loaded — binary=${config.binaryPath}${probe.version ? ` (${probe.version})` : ""}`
: `OpenClaw Runtime Plugin loaded but binary not detected: ${probe.reason ?? "unknown"}`,
);
ctx.emitEvent("openclaw-runtime:loaded", {
runtimeId: OPENCLAW_RUNTIME_ID,
version: OPENCLAW_RUNTIME_VERSION,
binaryAvailable: probe.available,
binaryPath: probe.binaryPath ?? config.binaryPath,
});
},
onUnload: () => {
// No context available during unload
// No persistent state to clean up — each prompt spawns a fresh subprocess.
},
},
runtime: {
@@ -58,4 +71,25 @@ const plugin: FusionPlugin = definePlugin({
export default plugin;
export { openclawRuntimeMetadata, openclawRuntimeFactory, OPENCLAW_RUNTIME_ID };
// ── Public exports ────────────────────────────────────────────────────────────
export { openclawRuntimeMetadata, openclawRuntimeFactory, OPENCLAW_RUNTIME_ID };
export { OpenClawRuntimeAdapter } from "./runtime-adapter.js";
export {
resolveCliConfig,
buildOpenClawArgs,
createCliSession,
promptCli,
describeCliModel,
extractStderrError,
configureOpenClawMcpServer,
} from "./pi-module.js";
export type { CliConfig, GatewaySession, OpenClawAgentJson } from "./types.js";
export {
toolsToMcpToolDefs,
writeOpenClawMcpBridgeFiles,
} from "./mcp-config.js";
// Probe re-export for the dashboard's runtime-provider-probes façade.
export { probeOpenClawBinary } from "./probe.js";
export type { OpenClawBinaryStatus } from "./probe.js";

View File

@@ -0,0 +1,60 @@
import { writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const BUILT_IN_TOOL_NAMES = new Set(["read", "write", "edit", "bash", "grep", "find"]);
export interface ToolLike {
name: string;
description?: string;
parameters?: Record<string, unknown>;
}
export interface McpToolDef {
name: string;
description: string;
inputSchema: Record<string, unknown>;
}
export interface OpenClawMcpBridgeFiles {
schemaPath: string;
serverConfigPath: string;
serverName: string;
}
export function toolsToMcpToolDefs(tools: ReadonlyArray<ToolLike> | undefined): McpToolDef[] {
if (!Array.isArray(tools)) return [];
return tools
.filter((tool) => tool && typeof tool.name === "string" && !BUILT_IN_TOOL_NAMES.has(tool.name))
.map((tool) => ({
name: tool.name,
description: typeof tool.description === "string" ? tool.description : "",
inputSchema: tool.parameters ?? { type: "object", properties: {} },
}));
}
export function writeOpenClawMcpBridgeFiles(toolDefs: McpToolDef[], cacheKey?: string): OpenClawMcpBridgeFiles {
const suffix = cacheKey ? `${process.pid}-${cacheKey}` : `${process.pid}`;
const schemaPath = join(tmpdir(), `openclaw-runtime-mcp-schemas-${suffix}.json`);
writeFileSync(schemaPath, JSON.stringify(toolDefs));
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const serverPath = join(__dirname, "mcp-schema-server.cjs");
const serverConfig = {
command: "node",
args: [serverPath, schemaPath],
};
const serverConfigPath = join(tmpdir(), `openclaw-runtime-mcp-server-${suffix}.json`);
writeFileSync(serverConfigPath, JSON.stringify(serverConfig));
return {
schemaPath,
serverConfigPath,
serverName: "fusion-custom-tools",
};
}

View File

@@ -0,0 +1,59 @@
"use strict";
const fs = require("fs");
const readline = require("readline");
const schemaPath = process.argv[2];
if (!schemaPath) process.exit(1);
let tools = [];
try {
tools = JSON.parse(fs.readFileSync(schemaPath, "utf-8"));
} catch {
process.exit(1);
}
const rl = readline.createInterface({ input: process.stdin });
rl.on("line", (line) => {
let msg;
try {
msg = JSON.parse(line);
} catch {
return;
}
if (msg.method === "initialize") {
process.stdout.write(
JSON.stringify({
jsonrpc: "2.0",
id: msg.id,
result: {
protocolVersion: "2024-11-05",
capabilities: { tools: {} },
serverInfo: { name: "fusion-custom-tools", version: "1.0.0" },
},
}) + "\n",
);
return;
}
if (msg.method === "tools/list") {
process.stdout.write(
JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: { tools } }) + "\n",
);
return;
}
if (msg.method === "tools/call") {
process.stdout.write(
JSON.stringify({
jsonrpc: "2.0",
id: msg.id,
result: {
content: [{ type: "text", text: "Tool execution is handled by Fusion runtime." }],
isError: true,
},
}) + "\n",
);
}
});

View File

@@ -1,53 +1,372 @@
/**
* Pi Module Seam
* OpenClaw CLI spawn module.
*
* Provides a mockable import path for pi functions used by the OpenClawRuntimeAdapter.
* Tests intercept this module via `vi.mock("../pi-module.js", ...)`. The runtime
* implementations come from @fusion/engine; the local types provide a loose
* surface so the adapter doesn't have to depend on @fusion/engine's full types.
* 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 {
createFnAgent as _createFnAgent,
promptWithFallback as _promptWithFallback,
describeModel as _describeModel,
} from "@fusion/engine";
export interface PiAgentSession {
dispose?: () => Promise<void> | void;
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;
}
export interface PiAgentResult {
session: PiAgentSession;
sessionFile?: string;
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;
}
export interface PiAgentOptions {
cwd: string;
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;
tools?: unknown;
customTools?: unknown;
onText?: (text: string) => void;
onThinking?: (text: string) => void;
onToolStart?: (toolName: string, args?: unknown) => void;
onToolEnd?: (toolName: string, result?: unknown) => void;
defaultProvider?: string;
defaultModelId?: string;
fallbackProvider?: string;
fallbackModelId?: string;
defaultThinkingLevel?: string;
sessionManager?: unknown;
skillSelection?: unknown;
skills?: 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,
};
}
export const createFnAgent = _createFnAgent as unknown as (
options: PiAgentOptions,
) => Promise<PiAgentResult>;
// ---------------------------------------------------------------------------
// configureOpenClawMcpServer — configure profile-scoped MCP server via CLI
// ---------------------------------------------------------------------------
export const promptWithFallback = _promptWithFallback as unknown as (
session: PiAgentSession,
prompt: string,
options?: unknown,
) => Promise<void>;
export async function configureOpenClawMcpServer(opts: {
binaryPath: string;
profile: string;
serverName: string;
serverConfigPath: string;
}): Promise<void> {
const serverValue = await readFile(opts.serverConfigPath, "utf-8");
export const describeModel = _describeModel as unknown as (session: PiAgentSession) => string;
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}`;
}

View File

@@ -0,0 +1,154 @@
/**
* Public helper for probing the locally-installed OpenClaw CLI binary.
*
* Used by dashboards, health checks, and onboarding flows to determine
* whether `openclaw` is available before attempting any agent session.
*
* Design choices mirror the Claude CLI probe in packages/dashboard:
* - No caching. PATH can change between requests.
* - Short timeout. A misbehaving shim must not block callers.
* - Never throws. Any failure is captured in the returned status object.
*/
import { spawn } from "node:child_process";
/** Default timeout for the version probe. */
const DEFAULT_PROBE_TIMEOUT_MS = 2_000;
/**
* Structured result from probing the OpenClaw CLI binary.
*/
export interface OpenClawBinaryStatus {
/** `true` if the binary was found on PATH and completed successfully. */
available: boolean;
/** Resolved binary path (from `which`/`where`), if determinable. */
binaryPath?: string;
/** Trimmed version string from `openclaw --version`, e.g. `"OpenClaw 2026.4.26 (be8c246)"`. */
version?: string;
/** Human-readable failure reason when `available === false`. */
reason?: string;
/** Wall-clock milliseconds consumed by the probe. */
probeDurationMs: number;
}
/**
* Spawn `openclaw --version` and return a structured availability status.
*
* Never throws — failures are represented as `available: false` with a
* descriptive `reason` so HTTP handlers can render provider cards safely.
*
* @param opts.binaryPath - Override the binary path. Defaults to `"openclaw"`.
* @param opts.timeoutMs - Max milliseconds to wait. Defaults to 2,000.
*/
export async function probeOpenClawBinary(
opts: { binaryPath?: string; timeoutMs?: number } = {},
): Promise<OpenClawBinaryStatus> {
const startedAt = Date.now();
const binary = opts.binaryPath ?? "openclaw";
const timeoutMs = opts.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
const resolvedPath = await tryResolveBinaryPath(binary);
return new Promise<OpenClawBinaryStatus>((resolvePromise) => {
const finish = (
partial: Omit<OpenClawBinaryStatus, "probeDurationMs">,
): void => {
resolvePromise({ ...partial, probeDurationMs: Date.now() - startedAt });
};
let settled = false;
const child = spawn(resolvedPath ?? binary, ["--version"], {
stdio: ["ignore", "pipe", "pipe"],
});
const timer = setTimeout(() => {
if (settled) return;
settled = true;
try {
child.kill("SIGKILL");
} catch {
// Process already gone.
}
finish({
available: false,
binaryPath: resolvedPath,
reason: `Probe timed out after ${timeoutMs}ms`,
});
}, timeoutMs);
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(timer);
const isNotFound = err.code === "ENOENT";
finish({
available: false,
binaryPath: resolvedPath,
reason: isNotFound
? `\`${binary}\` not found on PATH — install via: npm install -g openclaw`
: err.message,
});
});
child.on("close", (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (code === 0) {
finish({
available: true,
binaryPath: resolvedPath,
version: stdout.trim() || undefined,
});
} else {
finish({
available: false,
binaryPath: resolvedPath,
reason:
stderr.trim() ||
`openclaw --version exited with code ${String(code)}`,
});
}
});
});
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/**
* Attempt to resolve the absolute path to a binary via `which` (POSIX) or
* `where` (Windows). Returns `undefined` on failure — the probe above is the
* real authority.
*/
async function tryResolveBinaryPath(binary: string): Promise<string | undefined> {
return new Promise<string | undefined>((resolvePromise) => {
const which = process.platform === "win32" ? "where" : "which";
const child = spawn(which, [binary], { stdio: ["ignore", "pipe", "ignore"] });
let out = "";
child.stdout?.on("data", (chunk: Buffer) => {
out += chunk.toString("utf-8");
});
child.on("error", () => resolvePromise(undefined));
child.on("close", (code) => {
if (code === 0) {
const first = out.trim().split(/\r?\n/)[0];
resolvePromise(first?.length ? first : undefined);
} else {
resolvePromise(undefined);
}
});
});
}

View File

@@ -1,49 +1,98 @@
/**
* OpenClaw Runtime Adapter — drives the local `openclaw` CLI as a subprocess.
*
* Each call to `promptWithFallback` invokes
* `openclaw --no-color agent --local --json --session-id <uuid> --message <prompt>`
* (and `--model`, `--thinking`, `--timeout`, `--agent` if configured),
* parses the JSON document on stdout, and forwards visible/reasoning text
* via the session callbacks.
*
* Session continuity: the UUID minted on session create is reused on every
* subsequent prompt as `--session-id`, so openclaw resumes the same agent
* conversation server-side.
*/
import type {
AgentRuntime,
AgentRuntimeOptions,
AgentSession,
AgentSessionResult,
CliConfig,
GatewaySession,
} from "./types.js";
import { createFnAgent, describeModel, promptWithFallback } from "./pi-module.js";
const getModelDescription = describeModel;
import {
configureOpenClawMcpServer,
createCliSession,
describeCliModel,
promptCli,
resolveCliConfig,
} from "./pi-module.js";
import { toolsToMcpToolDefs, writeOpenClawMcpBridgeFiles, type ToolLike } from "./mcp-config.js";
import { randomUUID } from "node:crypto";
export class OpenClawRuntimeAdapter implements AgentRuntime {
readonly id = "openclaw";
readonly name = "OpenClaw Runtime";
private readonly config: CliConfig;
constructor(settings?: Partial<CliConfig> | Record<string, unknown>) {
this.config = resolveCliConfig(settings as Record<string, unknown> | undefined);
}
async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> {
return createFnAgent({
cwd: options.cwd,
systemPrompt: options.systemPrompt,
tools: options.tools,
customTools: options.customTools,
onText: options.onText,
onThinking: options.onThinking,
onToolStart: options.onToolStart,
onToolEnd: options.onToolEnd,
defaultProvider: options.defaultProvider,
defaultModelId: options.defaultModelId,
fallbackProvider: options.fallbackProvider,
fallbackModelId: options.fallbackModelId,
defaultThinkingLevel: options.defaultThinkingLevel,
sessionManager: options.sessionManager,
skillSelection: options.skillSelection,
skills: options.skills,
});
}
const contextTools = [
...(Array.isArray(options.tools) ? (options.tools as ToolLike[]) : []),
...(Array.isArray(options.customTools) ? (options.customTools as ToolLike[]) : []),
];
const toolDefs = toolsToMcpToolDefs(contextTools);
async promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise<void> {
return promptWithFallback(session, prompt, options);
}
let mcpProfile: string | undefined;
let mcpConfigPath: string | undefined;
describeModel(session: AgentSession): string {
return getModelDescription(session);
}
async dispose(session: AgentSession): Promise<void> {
if (typeof (session as { dispose?: () => Promise<void> }).dispose === "function") {
await (session as { dispose: () => Promise<void> }).dispose();
if (toolDefs.length > 0) {
const bridge = writeOpenClawMcpBridgeFiles(toolDefs, randomUUID());
mcpProfile = `fusion-${randomUUID()}`;
mcpConfigPath = bridge.serverConfigPath;
await configureOpenClawMcpServer({
binaryPath: this.config.binaryPath,
profile: mcpProfile,
serverName: bridge.serverName,
serverConfigPath: bridge.serverConfigPath,
});
}
const session = createCliSession({
systemPrompt: options.systemPrompt,
agentId: this.config.agentId,
mcpProfile,
mcpConfigPath,
callbacks: {
onText: options.onText,
onThinking: options.onThinking,
onToolStart: options.onToolStart,
onToolEnd: options.onToolEnd,
},
});
return { session, sessionFile: undefined };
}
async promptWithFallback(
session: GatewaySession,
prompt: string,
options?: unknown,
): Promise<void> {
const overrideCallbacks = (options ?? undefined) as
| Parameters<typeof promptCli>[3]
| undefined;
await promptCli(session, prompt, this.config, overrideCallbacks);
}
describeModel(session: GatewaySession): string {
return describeCliModel(session);
}
async dispose(_session: GatewaySession): Promise<void> {
// No persistent resources — each prompt spawns a fresh subprocess.
}
}

View File

@@ -1,25 +1,71 @@
/**
* OpenClaw Runtime Plugin - Type Definitions
* OpenClaw runtime adapter contracts.
*
* The runtime contract is defined locally to avoid compile-time coupling to
* internal engine exports.
* The runtime drives the local `openclaw` CLI as a subprocess (no daemon).
* Types are kept local to this package to stay decoupled from internal engine modules.
*/
/** Minimal session shape used by the runtime adapter. */
export interface AgentSession {
export type GatewayRole = "developer" | "user" | "assistant";
export interface GatewayMessage {
role: GatewayRole;
content: string;
}
/**
* Resolved settings used by the CLI spawn helpers.
*
* `useGateway: false` (default) passes `--local` to the openclaw CLI so the
* agent runs embedded with no daemon. `useGateway: true` lets the CLI try the
* WebSocket gateway and fall back to embedded if unreachable (slower).
*/
export interface CliConfig {
binaryPath: string;
agentId: string;
model?: string;
thinking: string; // off | minimal | low | medium | high | xhigh | adaptive | max
cliTimeoutSec: number; // openclaw-side timeout (0 = no limit)
cliTimeoutMs: number; // hard kill on our side
useGateway: boolean;
}
export interface GatewayCallbacks {
onText?: (text: string) => void;
onThinking?: (text: string) => void;
onToolStart?: (toolName: string, args?: unknown) => void;
onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void;
}
/**
* In-memory session object held by the adapter. The `sessionId` is passed to
* `openclaw agent --session-id` so consecutive calls share state server-side.
*
* `messages` is a transcript-style local mirror; the openclaw CLI does NOT
* receive this array — it manages its own session store under
* `~/.openclaw/agents/<agentId>/sessions/`.
*/
export interface GatewaySession {
sessionId: string;
agentId: string;
systemPrompt: string;
messages: GatewayMessage[];
/** Last-known model description; populated after each turn from the CLI JSON. */
lastModelDescription: string;
/** Last-known token usage from the CLI JSON's `meta.agentMeta.usage`. */
lastUsage?: Record<string, number>;
/** Optional profile that carries MCP server config for this session. */
mcpProfile?: string;
/** Optional path to the server JSON used to configure MCP for this session. */
mcpConfigPath?: string;
callbacks?: GatewayCallbacks;
dispose?: () => Promise<void> | void;
}
/** Options for creating an agent session. Mirrors createFnAgent inputs used by the adapter. */
export interface AgentRuntimeOptions {
export interface AgentRuntimeOptions extends GatewayCallbacks {
cwd: string;
systemPrompt: string;
tools?: unknown;
customTools?: unknown;
onText?: (text: string) => void;
onThinking?: (text: string) => void;
onToolStart?: (toolName: string, args?: unknown) => void;
onToolEnd?: (toolName: string, result?: unknown) => void;
defaultProvider?: string;
defaultModelId?: string;
fallbackProvider?: string;
@@ -30,18 +76,43 @@ export interface AgentRuntimeOptions {
skills?: string[];
}
/** Result of creating a session. */
export interface AgentSessionResult {
session: AgentSession;
session: GatewaySession;
sessionFile?: string;
}
/** Agent runtime adapter interface. */
export interface AgentRuntime {
id: string;
name: string;
createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult>;
promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise<void>;
describeModel(session: AgentSession): string;
dispose?(session: AgentSession): Promise<void>;
promptWithFallback(session: GatewaySession, prompt: string, options?: unknown): Promise<void>;
describeModel(session: GatewaySession): string;
dispose?(session: GatewaySession): Promise<void>;
}
/**
* Shape of the JSON document emitted by `openclaw agent --json` on stdout.
* Used by the CLI parser; declared here so callers can introspect the result.
*/
export interface OpenClawAgentJson {
payloads?: Array<{
text?: string;
isError?: boolean;
isReasoning?: boolean;
mediaUrl?: string;
mediaUrls?: string[];
}>;
meta?: {
durationMs?: number;
aborted?: boolean;
stopReason?: string;
finalAssistantVisibleText?: string;
error?: { kind: string; message: string };
agentMeta?: {
sessionId?: string;
provider?: string;
model?: string;
usage?: Record<string, number>;
};
};
}

View File

@@ -1,12 +1,20 @@
import { defineConfig } from "vitest/config";
import { fileURLToPath } from "node:url";
import { computeMaxWorkers } from "../../packages/core/src/__test-utils__/vitest-workers";
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? "2", 10);
const maxWorkers = Math.max(1, Math.min(4, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : 2));
process.env.VITEST_MAX_WORKERS = String(maxWorkers);
const maxWorkers = computeMaxWorkers();
export default defineConfig({
resolve: {
alias: {
"@fusion/core": fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)),
"@fusion/plugin-sdk": fileURLToPath(new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url)),
},
},
test: {
include: ["src/**/*.test.ts"],
setupFiles: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-setup.ts", import.meta.url))],
globalSetup: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-teardown.ts", import.meta.url))],
pool: "threads",
maxWorkers,
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } },