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:
@@ -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
|
||||
|
||||
@@ -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 30–60s. 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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"index.test.js","sourceRoot":"","sources":["../../src/__tests__/index.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,QAAQ,CAAC;AAEzE,MAAM,EAAE,iBAAiB,EAAE,sBAAsB,EAAE,iBAAiB,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC;IACzF,iBAAiB,EAAE,EAAE,CAAC,EAAE,EAAE;IAC1B,sBAAsB,EAAE,EAAE,CAAC,EAAE,EAAE;IAC/B,iBAAiB,EAAE,EAAE,CAAC,EAAE,EAAE,CAAC,eAAe,CAAC,eAAe,CAAC;CAC5D,CAAC,CAAC,CAAC;AAEJ,EAAE,CAAC,IAAI,CAAC,iBAAiB,EAAE,GAAG,EAAE,CAAC,CAAC;IAChC,aAAa,EAAE,iBAAiB;IAChC,kBAAkB,EAAE,sBAAsB;IAC1C,aAAa,EAAE,iBAAiB;CACjC,CAAC,CAAC,CAAC;AAEJ,OAAO,MAAM,EAAE,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAC3G,OAAO,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAC;AAmB/D,SAAS,iBAAiB,CAAC,YAAkC,EAAE;IAC7D,OAAO;QACL,QAAQ,EAAE,gCAAgC;QAC1C,QAAQ,EAAE,EAAE;QACZ,MAAM,EAAE;YACN,IAAI,EAAE,EAAE,CAAC,EAAE,EAAE;YACb,IAAI,EAAE,EAAE,CAAC,EAAE,EAAE;YACb,KAAK,EAAE,EAAE,CAAC,EAAE,EAAE;YACd,KAAK,EAAE,EAAE,CAAC,EAAE,EAAE;SACf;QACD,SAAS,EAAE,EAAE,CAAC,EAAE,EAAE;QAClB,SAAS,EAAE;YACT,OAAO,EAAE,EAAE,CAAC,EAAE,EAAE;SACjB;QACD,GAAG,SAAS;KACb,CAAC;AACJ,CAAC;AAED,QAAQ,CAAC,yBAAyB,EAAE,GAAG,EAAE;IACvC,UAAU,CAAC,GAAG,EAAE;QACd,EAAE,CAAC,aAAa,EAAE,CAAC;IACrB,CAAC,CAAC,CAAC;IAEH,SAAS,CAAC,GAAG,EAAE;QACb,EAAE,CAAC,eAAe,EAAE,CAAC;IACvB,CAAC,CAAC,CAAC;IAEH,QAAQ,CAAC,0BAA0B,EAAE,GAAG,EAAE;QACxC,EAAE,CAAC,qCAAqC,EAAE,GAAG,EAAE;YAC7C,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,gCAAgC,CAAC,CAAC;YAClE,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,yBAAyB,CAAC,CAAC;YAC7D,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;YAC9C,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC,SAAS,CAAC,UAAU,CAAC,CAAC;YAC1D,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC;YACnD,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;QACzC,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,QAAQ,CAAC,sBAAsB,EAAE,GAAG,EAAE;QACpC,EAAE,CAAC,2CAA2C,EAAE,GAAG,EAAE;YACnD,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,WAAW,EAAE,CAAC;YACrC,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,QAAQ,CAAC,SAAS,CAAC,CAAC,IAAI,CAAC,mBAAmB,CAAC,CAAC;YACrE,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,kBAAkB,CAAC,CAAC;YAC/D,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,QAAQ,CAAC,WAAW,CAAC,CAAC,SAAS,CAAC,4BAA4B,CAAC,CAAC;YACrF,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QACzD,CAAC,CAAC,CAAC;QAEH,EAAE,CAAC,qEAAqE,EAAE,GAAG,EAAE;YAC7E,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC,uBAAuB,CAAC,CAAC;YACjE,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC,OAAO,CAAC,uBAAuB,CAAC,CAAC;QACpE,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,QAAQ,CAAC,OAAO,EAAE,GAAG,EAAE;QACrB,EAAE,CAAC,yDAAyD,EAAE,KAAK,IAAI,EAAE;YACvE,MAAM,GAAG,GAAG,iBAAiB,EAAE,CAAC;YAChC,MAAM,MAAM,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC,GAAU,CAAC,CAAC;YAExC,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,oBAAoB,CAAC,gCAAgC,CAAC,CAAC;YAC/E,MAAM,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,oBAAoB,CAAC,yBAAyB,EAAE;gBACpE,SAAS,EAAE,mBAAmB;gBAC9B,OAAO,EAAE,OAAO;aACjB,CAAC,CAAC;QACL,CAAC,CAAC,CAAC;QAEH,EAAE,CAAC,2BAA2B,EAAE,GAAG,EAAE;YACnC,MAAM,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,OAAO,EAAE,CAAC;QACxD,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,QAAQ,CAAC,0BAA0B,EAAE,GAAG,EAAE;QACxC,EAAE,CAAC,iCAAiC,EAAE,GAAG,EAAE;YACzC,MAAM,CAAC,mBAAmB,CAAC,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;YAC7C,MAAM,CAAC,uBAAuB,CAAC,SAAS,CAAC,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;YAC3D,MAAM,CAAC,OAAO,sBAAsB,CAAC,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;QACzD,CAAC,CAAC,CAAC;QAEH,EAAE,CAAC,0DAA0D,EAAE,KAAK,IAAI,EAAE;YACxE,MAAM,OAAO,GAAG,CAAC,MAAM,sBAAsB,CAAC,iBAAiB,EAAS,CAAC,CAA2B,CAAC;YAErG,MAAM,CAAC,OAAO,CAAC,CAAC,cAAc,CAAC,sBAAsB,CAAC,CAAC;YACvD,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;YACpC,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,kBAAkB,CAAC,CAAC;YAC9C,MAAM,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,cAAc,CAAC,QAAQ,CAAC,CAAC;YAC7C,MAAM,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,cAAc,CAAC,SAAS,CAAC,CAAC;QAChD,CAAC,CAAC,CAAC;QAEH,EAAE,CAAC,mCAAmC,EAAE,KAAK,IAAI,EAAE;YACjD,MAAM,MAAM,CAAC,sBAAsB,CAAC,iBAAiB,EAAS,CAAC,CAAC,CAAC,QAAQ,CAAC,cAAc,CACtF,sBAAsB,CACvB,CAAC;QACJ,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
|
||||
{"version":3,"file":"index.test.js","sourceRoot":"","sources":["../../src/__tests__/index.test.ts"],"names":[],"mappings":"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"}
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
60
plugins/fusion-plugin-openclaw-runtime/src/mcp-config.ts
Normal file
60
plugins/fusion-plugin-openclaw-runtime/src/mcp-config.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
154
plugins/fusion-plugin-openclaw-runtime/src/probe.ts
Normal file
154
plugins/fusion-plugin-openclaw-runtime/src/probe.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
Reference in New Issue
Block a user