From b563b126627f3904bf171936dcef532f21cd63e5 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 14 Jul 2026 08:18:52 -0700 Subject: [PATCH] feat: add Oh My Pi (omp) ACP runtime plugin (#2083) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add `fusion-plugin-omp-runtime` so Fusion agents can run through operator-installed **Oh My Pi (`omp`)** over the [Agent Client Protocol](https://omp.sh/docs/acp) (`omp acp`). - Wire staged/bundled install, Settings → Authentication card (enable + binary path), model discovery (`omp models` → `omp-cli/*`), and MCP eligibility for runtime id `omp`. - Forward Fusion `systemPrompt` via ACP `session/new` `_meta.systemPromptOverride`. ## How operators use it 1. Install/auth `omp` (credentials under `~/.omp`). 2. Enable **Oh My Pi — via omp ACP** in Settings → Authentication (optional binary path). 3. Set agent **Runtime Source → OMP Runtime** (`runtimeHint: "omp"`), or pick an `omp-cli/*` model when enabled. ## Known v1 gaps - No Grok-style Fusion `fn_*` loopback tool bridge yet (operator MCP is forwarded; in-process custom tools are not). - Model is fixed at spawn (`omp --model … acp`); no mid-session Fusion model switch. ## Test plan - [x] `pnpm --filter @fusion-plugin-examples/omp-runtime test` (unit + live ACP when `omp` is on PATH) - [x] Auth routes: `POST /api/auth/omp-cli`, `GET /api/providers/omp-cli/status` - [x] Engine `runtimeSupportsMcp("omp")` - [ ] Manual: enable card in dashboard, select OMP runtime on an agent, run a short chat turn ## Summary by CodeRabbit * **New Features** * Added Oh My Pi (OMP) CLI support as an ACP-backed runtime and model provider, including model discovery and probing. * Added dashboard auth/status controls to enable OMP, check readiness, and configure the local binary path (with validation). * Exposed OMP custom `fn_*` tools via an MCP loopback bridge, plus optional filesystem capabilities and stricter tool permission gating. * **Documentation** * Added/expanded OMP runtime contract and integration docs (including the ACP session/handshake flow). * **Tests** * Added Vitest coverage for settings wiring, provider status, model discovery, runtime sessions, permissions, MCP bridging, and live connectivity. --- docs/omp-acp-contract.md | 78 +++ .../src/plugins/staged-bundled-plugin-ids.ts | 2 + packages/cli/tsup.config.ts | 2 + packages/cli/vitest.config.ts | 12 + .../src/__tests__/omp-cli-settings.test.ts | 34 ++ .../src/plugins/bundled-plugin-install.ts | 2 + packages/core/src/settings-schema.ts | 6 + packages/core/src/types.ts | 12 + packages/dashboard/app/api/legacy.ts | 48 ++ .../app/components/OmpCliProviderCard.css | 67 +++ .../app/components/OmpCliProviderCard.tsx | 216 +++++++ .../sections/AuthenticationSection.tsx | 7 +- .../settings-default-descriptions.test.tsx | 2 + packages/dashboard/package.json | 1 + .../src/__tests__/routes-auth.test.ts | 82 ++- packages/dashboard/src/omp-model-cache.ts | 99 ++++ packages/dashboard/src/routes.ts | 1 + .../src/routes/register-auth-routes.ts | 125 +++- .../src/routes/register-model-routes.ts | 35 ++ .../dashboard/src/runtime-provider-probes.ts | 24 + packages/dashboard/vitest.config.ts | 13 + .../src/__tests__/mcp-runtime-support.test.ts | 2 + packages/engine/src/mcp-runtime-support.ts | 16 +- .../fusion-plugin-omp-runtime/CHANGELOG.md | 11 + plugins/fusion-plugin-omp-runtime/README.md | 104 ++++ .../fusion-plugin-omp-runtime/manifest.json | 6 + .../fusion-plugin-omp-runtime/package.json | 46 ++ .../src/__tests__/acp-settings.test.ts | 62 ++ .../src/__tests__/index.test.ts | 15 + .../src/__tests__/mcp-forwarding.test.ts | 54 ++ .../src/__tests__/omp-acp-live.test.ts | 97 ++++ .../src/__tests__/probe.test.ts | 25 + .../src/__tests__/runtime-adapter.test.ts | 191 +++++++ .../src/__tests__/tool-bridge.test.ts | 101 ++++ .../src/acp-settings.ts | 131 +++++ .../src/acp/VENDORED.md | 22 + .../src/acp/cli-spawn.ts | 203 +++++++ .../src/acp/control-handler.ts | 302 ++++++++++ .../src/acp/event-bridge.ts | 313 ++++++++++ .../src/acp/fs-capabilities.ts | 258 +++++++++ .../src/acp/index.ts | 21 + .../src/acp/path-jail.ts | 229 ++++++++ .../src/acp/process-manager.ts | 163 ++++++ .../src/acp/prompt-builder.ts | 50 ++ .../src/acp/provider.ts | 541 ++++++++++++++++++ .../src/acp/runtime-adapter.ts | 191 +++++++ .../src/acp/sanitize.ts | 81 +++ .../src/acp/tool-mapping.ts | 47 ++ .../src/acp/types.ts | 189 ++++++ .../src/cli-spawn.ts | 61 ++ .../fusion-plugin-omp-runtime/src/index.ts | 104 ++++ .../src/mcp-forwarding.ts | 114 ++++ .../src/mcp-schema-server.cjs | 151 +++++ .../fusion-plugin-omp-runtime/src/probe.ts | 73 +++ .../src/process-manager.ts | 70 +++ .../fusion-plugin-omp-runtime/src/provider.ts | 25 + .../src/runtime-adapter.ts | 421 ++++++++++++++ .../src/tool-bridge.ts | 200 +++++++ .../fusion-plugin-omp-runtime/src/types.ts | 120 ++++ .../fusion-plugin-omp-runtime/tsconfig.json | 10 + .../vitest.config.ts | 22 + pnpm-lock.yaml | 31 + pnpm-workspace.yaml | 1 + 63 files changed, 5736 insertions(+), 6 deletions(-) create mode 100644 docs/omp-acp-contract.md create mode 100644 packages/core/src/__tests__/omp-cli-settings.test.ts create mode 100644 packages/dashboard/app/components/OmpCliProviderCard.css create mode 100644 packages/dashboard/app/components/OmpCliProviderCard.tsx create mode 100644 packages/dashboard/src/omp-model-cache.ts create mode 100644 plugins/fusion-plugin-omp-runtime/CHANGELOG.md create mode 100644 plugins/fusion-plugin-omp-runtime/README.md create mode 100644 plugins/fusion-plugin-omp-runtime/manifest.json create mode 100644 plugins/fusion-plugin-omp-runtime/package.json create mode 100644 plugins/fusion-plugin-omp-runtime/src/__tests__/acp-settings.test.ts create mode 100644 plugins/fusion-plugin-omp-runtime/src/__tests__/index.test.ts create mode 100644 plugins/fusion-plugin-omp-runtime/src/__tests__/mcp-forwarding.test.ts create mode 100644 plugins/fusion-plugin-omp-runtime/src/__tests__/omp-acp-live.test.ts create mode 100644 plugins/fusion-plugin-omp-runtime/src/__tests__/probe.test.ts create mode 100644 plugins/fusion-plugin-omp-runtime/src/__tests__/runtime-adapter.test.ts create mode 100644 plugins/fusion-plugin-omp-runtime/src/__tests__/tool-bridge.test.ts create mode 100644 plugins/fusion-plugin-omp-runtime/src/acp-settings.ts create mode 100644 plugins/fusion-plugin-omp-runtime/src/acp/VENDORED.md create mode 100644 plugins/fusion-plugin-omp-runtime/src/acp/cli-spawn.ts create mode 100644 plugins/fusion-plugin-omp-runtime/src/acp/control-handler.ts create mode 100644 plugins/fusion-plugin-omp-runtime/src/acp/event-bridge.ts create mode 100644 plugins/fusion-plugin-omp-runtime/src/acp/fs-capabilities.ts create mode 100644 plugins/fusion-plugin-omp-runtime/src/acp/index.ts create mode 100644 plugins/fusion-plugin-omp-runtime/src/acp/path-jail.ts create mode 100644 plugins/fusion-plugin-omp-runtime/src/acp/process-manager.ts create mode 100644 plugins/fusion-plugin-omp-runtime/src/acp/prompt-builder.ts create mode 100644 plugins/fusion-plugin-omp-runtime/src/acp/provider.ts create mode 100644 plugins/fusion-plugin-omp-runtime/src/acp/runtime-adapter.ts create mode 100644 plugins/fusion-plugin-omp-runtime/src/acp/sanitize.ts create mode 100644 plugins/fusion-plugin-omp-runtime/src/acp/tool-mapping.ts create mode 100644 plugins/fusion-plugin-omp-runtime/src/acp/types.ts create mode 100644 plugins/fusion-plugin-omp-runtime/src/cli-spawn.ts create mode 100644 plugins/fusion-plugin-omp-runtime/src/index.ts create mode 100644 plugins/fusion-plugin-omp-runtime/src/mcp-forwarding.ts create mode 100644 plugins/fusion-plugin-omp-runtime/src/mcp-schema-server.cjs create mode 100644 plugins/fusion-plugin-omp-runtime/src/probe.ts create mode 100644 plugins/fusion-plugin-omp-runtime/src/process-manager.ts create mode 100644 plugins/fusion-plugin-omp-runtime/src/provider.ts create mode 100644 plugins/fusion-plugin-omp-runtime/src/runtime-adapter.ts create mode 100644 plugins/fusion-plugin-omp-runtime/src/tool-bridge.ts create mode 100644 plugins/fusion-plugin-omp-runtime/src/types.ts create mode 100644 plugins/fusion-plugin-omp-runtime/tsconfig.json create mode 100644 plugins/fusion-plugin-omp-runtime/vitest.config.ts diff --git a/docs/omp-acp-contract.md b/docs/omp-acp-contract.md new file mode 100644 index 0000000000..0d69a0ebb4 --- /dev/null +++ b/docs/omp-acp-contract.md @@ -0,0 +1,78 @@ +# OMP ACP Runtime Contract + +Date: 2026-07-11 + +Launch/readiness contract for `fusion-plugin-omp-runtime`, which drives +[Oh My Pi (`omp`)](https://omp.sh/) over the +[Agent Client Protocol](https://agentclientprotocol.com) (`omp acp`). + +Mirrors the shape of `docs/acp-contract.md` and `docs/grok-cli-contract.md`. + +## Transport + +- **Newline-delimited JSON-RPC 2.0 over stdio** via `@agentclientprotocol/sdk` + (`ndJsonStream` + `ClientSideConnection`), vendored under the plugin’s + `src/acp/` (same client as Grok ACP — not imported from the experimental + `fusion-plugin-acp-runtime` package). +- Fusion launches `omp` as a subprocess with piped stdio. +- `stderr` is captured for diagnostics, never parsed as protocol. + +## Invocation + +```bash +omp acp +# optional model: +omp --model acp +# equivalent mode flag: +omp --mode acp +``` + +Upstream docs: https://omp.sh/docs/acp + +## Binary detection / readiness + +- Probe: `omp --version` (exit 0 ⇒ available). +- Auth is owned by the local `omp` install under `~/.omp` (provider keys / OAuth). + Fusion does not require a Fusion-visible API key. +- ACP handshake: `initialize` → `authenticate` (prefer method `agent`) → + `session/new` → `session/prompt` turns. + +## Env isolation + +Subprocess env is built from `OMP_ACP_ENV_ALLOWLIST` only (HOME/PATH/XDG + common +provider key names). Inherited `process.env` is **not** forwarded. + +## Failure surface + +| Situation | Behavior | +| --- | --- | +| Binary missing | Probe `available: false`; createSession emits onText diagnostic | +| ACP handshake fail | Dead session + visible onText diagnostic (never silent empty) | +| Mid-turn error | Partial text kept; empty turn gets diagnostic | +| Dispose / no connection | Follow-up prompts re-surface connection diagnostic | + +## External integration evidence + +- Canonical upstream repo: https://github.com/can1357/oh-my-pi +- Docs / homepage: https://omp.sh/ · https://omp.sh/docs/acp +- Release / download: https://github.com/can1357/oh-my-pi/releases · installer script / npm `@oh-my-pi/pi-coding-agent` +- Binary / CLI name: `omp` +- Checksum: `upstream-pending-verification` (operator-installed) + +## Plugin metadata + +- Plugin ID: `fusion-plugin-omp-runtime` +- Runtime ID: `omp` +- Provider ID: `omp-cli` +- Package: `@fusion-plugin-examples/omp-runtime` +- Global settings: `useOmpCli`, `ompCliBinaryPath` +- Auth routes: `POST /api/auth/omp-cli`, `GET /api/providers/omp-cli/status` + +## Fusion context delivery + +- `systemPrompt` is forwarded on ACP `session/new` as `_meta.systemPromptOverride` + (plus rules describing Fusion MCP tools when present). +- Operator MCP servers are eligible for `session/new.mcpServers` (`runtimeSupportsMcp("omp")`). +- Fusion in-process `fn_*` custom tools are bridged via loopback HTTP + stdio MCP + server `fusion-custom-tools` (`mcp-schema-server.cjs`, env + `FUSION_OMP_TOOL_BRIDGE_URL`) — same pattern as Grok ACP. diff --git a/packages/cli/src/plugins/staged-bundled-plugin-ids.ts b/packages/cli/src/plugins/staged-bundled-plugin-ids.ts index e9c99b29d9..633b3a4fa2 100644 --- a/packages/cli/src/plugins/staged-bundled-plugin-ids.ts +++ b/packages/cli/src/plugins/staged-bundled-plugin-ids.ts @@ -4,6 +4,8 @@ export const RUNTIME_PLUGIN_IDS = [ "fusion-plugin-paperclip-runtime", "fusion-plugin-cursor-runtime", "fusion-plugin-grok-runtime", + // FNXC:OmpAcp 2026-07-11-23:35: Oh My Pi ACP runtime (omp acp) — staged like acp/droid for explicit runtime use. + "fusion-plugin-omp-runtime", "fusion-plugin-droid-runtime", "fusion-plugin-acp-runtime", ] as const; diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index e8bb009835..c54919781c 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -14,6 +14,8 @@ const RUNTIME_PLUGINS_WITH_MCP_SCHEMA_SERVER = new Set([ // FNXC:GrokAcp 2026-07-11-14:00: Grok ACP ships mcp-schema-server.cjs so // session/new can forward executable Fusion fn_* tools to grok agent stdio. "fusion-plugin-grok-runtime", + // FNXC:OmpAcp 2026-07-14-00:05: OMP ACP ships the same bridge asset for fn_* tools. + "fusion-plugin-omp-runtime", ]); const __dirname = dirname(fileURLToPath(import.meta.url)); diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index 9e27a366f5..7cec3703a8 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -152,6 +152,18 @@ export default defineConfig({ replacement: resolve(__dirname, "../../plugins/fusion-plugin-grok-runtime/src/index.ts"), }, /* + FNXC:OmpAcp 2026-07-11-23:35: + runtime-provider-probes imports @fusion-plugin-examples/omp-runtime; alias source for checkout tests. + */ + { + find: /^@fusion-plugin-examples\/omp-runtime\/probe$/, + replacement: resolve(__dirname, "../../plugins/fusion-plugin-omp-runtime/src/probe.ts"), + }, + { + find: /^@fusion-plugin-examples\/omp-runtime$/, + replacement: resolve(__dirname, "../../plugins/fusion-plugin-omp-runtime/src/index.ts"), + }, + /* FNXC:PluginTests 2026-07-04-09:30: The roadmap plugin (@fusion-plugin-examples/roadmap) is imported by the CLI extension. Without source aliases, Vite resolves to the dist/ exports which don't exist in a source checkout. */ diff --git a/packages/core/src/__tests__/omp-cli-settings.test.ts b/packages/core/src/__tests__/omp-cli-settings.test.ts new file mode 100644 index 0000000000..786eba30a1 --- /dev/null +++ b/packages/core/src/__tests__/omp-cli-settings.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import type { GlobalSettings } from "../types.js"; +import { + DEFAULT_GLOBAL_SETTINGS, + GLOBAL_SETTINGS_KEYS, + isGlobalSettingsKey, +} from "../settings-schema.js"; + +describe("OMP CLI global settings", () => { + it("includes the enable toggle and binary path in GLOBAL_SETTINGS_KEYS", () => { + expect(GLOBAL_SETTINGS_KEYS).toContain("useOmpCli"); + expect(GLOBAL_SETTINGS_KEYS).toContain("ompCliBinaryPath"); + }); + + it("defaults both OMP CLI settings to undefined", () => { + expect(DEFAULT_GLOBAL_SETTINGS.useOmpCli).toBeUndefined(); + expect(DEFAULT_GLOBAL_SETTINGS.ompCliBinaryPath).toBeUndefined(); + }); + + it("recognizes omp CLI settings keys", () => { + expect(isGlobalSettingsKey("ompCliBinaryPath")).toBe(true); + expect(isGlobalSettingsKey("useOmpCli")).toBe(true); + }); + + it("accepts a string binary override distinct from the enable toggle", () => { + const configured: GlobalSettings = { + useOmpCli: false, + ompCliBinaryPath: "/usr/local/bin/omp", + }; + + expect(configured.useOmpCli).toBe(false); + expect(configured.ompCliBinaryPath).toBe("/usr/local/bin/omp"); + }); +}); diff --git a/packages/core/src/plugins/bundled-plugin-install.ts b/packages/core/src/plugins/bundled-plugin-install.ts index 5e7c9efbb9..a9802456d3 100644 --- a/packages/core/src/plugins/bundled-plugin-install.ts +++ b/packages/core/src/plugins/bundled-plugin-install.ts @@ -35,6 +35,8 @@ export const BUNDLED_PLUGIN_IDS = [ "fusion-plugin-paperclip-runtime", "fusion-plugin-cursor-runtime", "fusion-plugin-grok-runtime", + // FNXC:OmpAcp 2026-07-11-23:35: Oh My Pi ACP runtime available as a staged/bundled install target. + "fusion-plugin-omp-runtime", "fusion-plugin-cli-printing-press", "fusion-plugin-compound-engineering", "fusion-plugin-linear-import", diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 55b6589c08..ad73f6f424 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -183,6 +183,12 @@ export const DEFAULT_GLOBAL_SETTINGS = { Grok CLI binary overrides are global operator settings because executable locations are machine-local. Blank/undefined preserves PATH auto-detection through grok. */ grokCliBinaryPath: undefined, + /* + FNXC:OmpAcp 2026-07-13-22:50: + Oh My Pi (omp) CLI enable + binary override are global operator settings (machine-local), mirroring Grok/Cursor. + */ + useOmpCli: undefined, + ompCliBinaryPath: undefined, // Global baseline lanes for per-role model selection executionGlobalProvider: undefined, executionGlobalModelId: undefined, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 8140aaa4f9..3276bc7565 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -3472,6 +3472,18 @@ export interface GlobalSettings { * Operators need a global machine-local Grok CLI executable override when PATH discovery resolves the wrong `grok`/`.cmd`/`.bat` shim. Blank/undefined means Fusion must keep auto-detecting through PATH candidates. */ grokCliBinaryPath?: string; + /** + * FNXC:OmpAcp 2026-07-13-22:50: + * When true, enable Oh My Pi (omp) CLI model-provider support (provider ID: `omp-cli`) + * through an operator-local `omp` install driven over ACP (`omp acp`). + */ + useOmpCli?: boolean; + /** + * FNXC:OmpAcp 2026-07-13-22:50: + * Global machine-local OMP CLI executable override when PATH discovery resolves the wrong + * `omp`/`.cmd`/`.bat` shim. Blank/undefined means PATH auto-detection. + */ + ompCliBinaryPath?: string; /** Global baseline AI model provider for task execution (executor agent). * This is the global lane that project-level `executionProvider` can override. * Must be set together with `executionGlobalModelId`. Falls back to diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index c363a19e12..5b8e69a436 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -2031,6 +2031,28 @@ export interface GrokCliStatus { ready: boolean; } +/* +FNXC:OmpAcp 2026-07-13-22:50: +Status shape for Settings → Oh My Pi (omp) ACP card. ready = enabled + binary available; auth under ~/.omp. +*/ +export interface OmpCliStatus { + binary: { + available: boolean; + authenticated?: boolean; + version?: string; + binaryPath?: string; + configuredBinaryPath?: string; + usingConfiguredBinaryPath?: boolean; + diagnostics?: string[]; + reason?: string; + probeDurationMs: number; + }; + enabled: boolean; + binaryPath?: string; + extension: null; + ready: boolean; +} + export interface LlamaCppStatus { enabled: boolean; extension: { @@ -2107,6 +2129,10 @@ export function fetchGrokCliStatus(): Promise { return api("/providers/grok-cli/status"); } +export function fetchOmpCliStatus(): Promise { + return api("/providers/omp-cli/status"); +} + /** Probe llama.cpp server + setting + extension state. */ export function fetchLlamaCppStatus(): Promise { return api("/providers/llama-cpp/status"); @@ -2407,6 +2433,28 @@ export function setGrokCliBinaryPath( }); } +/* +FNXC:OmpAcp 2026-07-13-22:50: +Client helpers for Oh My Pi ACP enable + binary path (mirror Grok/Cursor). +*/ +export function setOmpCliEnabled( + enabled: boolean, +): Promise<{ enabled: boolean; binaryPath?: string; restartRequired: boolean }> { + return api<{ enabled: boolean; binaryPath?: string; restartRequired: boolean }>("/auth/omp-cli", { + method: "POST", + body: JSON.stringify({ enabled }), + }); +} + +export function setOmpCliBinaryPath( + binaryPath: string | null, +): Promise<{ enabled: boolean; binaryPath?: string; restartRequired: boolean }> { + return api<{ enabled: boolean; binaryPath?: string; restartRequired: boolean }>("/auth/omp-cli", { + method: "POST", + body: JSON.stringify({ binaryPath }), + }); +} + /** Enable or disable the llama.cpp provider. */ export function setLlamaCppEnabled( enabled: boolean, diff --git a/packages/dashboard/app/components/OmpCliProviderCard.css b/packages/dashboard/app/components/OmpCliProviderCard.css new file mode 100644 index 0000000000..c43102d4c5 --- /dev/null +++ b/packages/dashboard/app/components/OmpCliProviderCard.css @@ -0,0 +1,67 @@ +.omp-cli-provider-card .auth-provider-cli-actions, +.omp-cli-provider-card .onboarding-provider-card__actions { + display: flex; + gap: var(--space-sm); + align-items: center; +} + +/* +FNXC:OmpAcp 2026-07-13-22:50: +Compact card body inset to match auth-provider-header horizontal padding (same as Grok CLI card). +*/ +.omp-cli-provider-card__body { + display: flex; + flex-direction: column; + gap: var(--space-sm); + padding: 0 var(--space-md) var(--space-sm); +} + +.omp-cli-provider-card__hint { + opacity: 0.75; +} + +.omp-cli-binary-path-control { + display: grid; + gap: var(--space-xs); + margin-top: var(--space-sm); +} + +.omp-cli-binary-path-label { + color: var(--text-secondary); + font-size: 0.78rem; + font-weight: 600; +} + +.omp-cli-binary-path-row { + display: flex; + gap: var(--space-sm); + align-items: center; +} + +.omp-cli-binary-path-input { + min-width: 0; + flex: 1 1 18rem; + height: 2rem; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + background: var(--surface-elevated); + color: var(--text-primary); + padding: 0 var(--space-sm); +} + +@media (max-width: 768px) { + .omp-cli-provider-card .auth-provider-cli-actions, + .omp-cli-provider-card .onboarding-provider-card__actions, + .omp-cli-binary-path-row { + flex-wrap: wrap; + } + + .omp-cli-binary-path-row .btn, + .omp-cli-binary-path-input { + width: 100%; + } + + .omp-cli-provider-card__body { + padding: 0 var(--space-sm) var(--space-sm); + } +} diff --git a/packages/dashboard/app/components/OmpCliProviderCard.tsx b/packages/dashboard/app/components/OmpCliProviderCard.tsx new file mode 100644 index 0000000000..5ec6fa10db --- /dev/null +++ b/packages/dashboard/app/components/OmpCliProviderCard.tsx @@ -0,0 +1,216 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Loader2 } from "lucide-react"; +import { fetchOmpCliStatus, setOmpCliBinaryPath, setOmpCliEnabled, type OmpCliStatus } from "../api"; +import { ProviderIcon } from "./ProviderIcon"; +import "./OmpCliProviderCard.css"; + +interface OmpCliProviderCardProps { + authenticated: boolean; + compact?: boolean; + onToggled?: (nextEnabled: boolean) => void; +} + +/* +FNXC:OmpAcp 2026-07-13-22:50: +Settings → Authentication card for Oh My Pi (omp) ACP. Ready = enabled + binary +available; omp owns auth under ~/.omp. Mirrors GrokCliProviderCard enable/path UX. +*/ +export function OmpCliProviderCard({ authenticated, compact = false, onToggled }: OmpCliProviderCardProps) { + const { t } = useTranslation("app"); + const [status, setStatus] = useState(null); + const [busy, setBusy] = useState<"enabling" | "disabling" | "testing" | "saving-path" | null>(null); + const [binaryPathInput, setBinaryPathInput] = useState(""); + const [pathMessage, setPathMessage] = useState<{ tone: "success" | "error"; text: string } | null>(null); + const [statusMessage, setStatusMessage] = useState<{ tone: "success" | "error"; text: string } | null>(null); + const pathDirtyRef = useRef(false); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const refresh = useCallback(async () => { + try { + const next = await fetchOmpCliStatus(); + if (mountedRef.current) { + setStatus(next); + setBinaryPathInput((current) => (pathDirtyRef.current ? current : (next.binaryPath ?? ""))); + setStatusMessage(null); + } + return next; + } catch (error) { + if (mountedRef.current) { + const message = error instanceof Error ? error.message : String(error); + setStatusMessage({ + tone: "error", + text: message || t("setup.ompCli.probeFailed", "Failed to probe local omp CLI."), + }); + } + return null; + } + }, [t]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const handleToggle = useCallback( + async (next: boolean) => { + setBusy(next ? "enabling" : "disabling"); + setStatusMessage(null); + try { + const result = await setOmpCliEnabled(next); + onToggled?.(result.enabled); + await refresh(); + } catch (error) { + if (mountedRef.current) { + const message = error instanceof Error ? error.message : String(error); + setStatusMessage({ + tone: "error", + text: message || t("setup.ompCli.toggleFailed", "Failed to update OMP CLI enable state."), + }); + } + } finally { + if (mountedRef.current) setBusy(null); + } + }, + [onToggled, refresh, t], + ); + + const currentlyEnabled = status?.enabled ?? authenticated; + const binaryAvailable = status?.binary.available ?? false; + const trimmedBinaryPath = binaryPathInput.trim(); + const savedBinaryPath = status?.binaryPath ?? ""; + const binaryPathChanged = trimmedBinaryPath !== savedBinaryPath; + + const handleBinaryPathChange = useCallback((value: string) => { + setBinaryPathInput(value); + pathDirtyRef.current = true; + setPathMessage(null); + }, []); + + const handleSaveBinaryPath = useCallback(async () => { + setBusy("saving-path"); + setPathMessage(null); + try { + await setOmpCliBinaryPath(trimmedBinaryPath || null); + if (!mountedRef.current) return; + pathDirtyRef.current = false; + const refreshed = await fetchOmpCliStatus(); + if (mountedRef.current) { + setStatus(refreshed); + setBinaryPathInput(refreshed.binaryPath ?? ""); + setPathMessage({ + tone: "success", + text: trimmedBinaryPath + ? t("setup.ompCli.pathSaved", "Binary path saved and tested.") + : t("setup.ompCli.pathCleared", "Binary path cleared; PATH auto-detection is active."), + }); + } + } catch (error) { + if (mountedRef.current) { + const message = error instanceof Error ? error.message : String(error); + setPathMessage({ tone: "error", text: message }); + } + } finally { + if (mountedRef.current) setBusy(null); + } + }, [t, trimmedBinaryPath]); + + const binaryPathControl = compact ? ( +
+ +
+ handleBinaryPathChange(event.target.value)} + placeholder={t("setup.ompCli.binaryPathPlaceholder", "/usr/local/bin/omp")} + disabled={busy !== null} + /> + +
+ {t("setup.ompCli.binaryPathHelp", "Leave blank to use PATH auto-detection (`omp`).")} + {pathMessage ? {pathMessage.text} : null} +
+ ) : null; + + const actions = ( + <> + + {currentlyEnabled ? ( + + ) : ( + + )} + + ); + + const statusText = !status + ? t("setup.ompCli.probing", "Probing local CLI…") + : !status.binary.available + ? status.binary.reason ?? t("setup.ompCli.binaryNotFound", "`omp` not found on PATH") + : currentlyEnabled + ? t("setup.ompCli.connected", "Connected{{version}}", { version: status.binary.version ? ` — ${status.binary.version}` : "" }) + : t("setup.ompCli.detectedPrompt", "Detected. Click Enable to route through omp ACP."); + + if (compact) { + return ( +
+
+
+ + {t("setup.ompCli.providerName", "Oh My Pi — via omp ACP")} + {currentlyEnabled ? t("setup.ompCli.active", "✓ Active") : t("setup.ompCli.notConnected", "✗ Not connected")} +
+
{actions}
+
+
+ {statusText} + {statusMessage ? ( + {statusMessage.text} + ) : null} + + {t("setup.ompCli.authHint", "Credentials live under ~/.omp (agent auth). Fusion does not store omp API keys.")} + + {binaryPathControl} +
+
+ ); + } + + return ( +
+
+ +
+
+ {t("setup.ompCli.providerName", "Oh My Pi — via omp ACP")} + {t("setup.ompCli.description", "Drive sessions through your local omp ACP server (`omp acp`).")} + {statusText} +
+
{actions}
+
+ ); +} diff --git a/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx b/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx index da69ce5ce2..7a45b306b6 100644 --- a/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx +++ b/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next"; import { ClaudeCliProviderCard } from "../../ClaudeCliProviderCard"; import { CursorCliProviderCard } from "../../CursorCliProviderCard"; import { GrokCliProviderCard } from "../../GrokCliProviderCard"; +import { OmpCliProviderCard } from "../../OmpCliProviderCard"; import { LlamaCppProviderCard } from "../../LlamaCppProviderCard"; import { ProviderIcon } from "../../ProviderIcon"; import { PluginSlot } from "../../PluginSlot"; @@ -83,7 +84,8 @@ export function AuthenticationSection({ auth }: AuthenticationSectionProps) { const visibleAuthProviders = hasSeparatedAnthropicProvider ? authProviders.filter((p) => p.id !== "anthropic") : authProviders; - const isSupportedCliProvider = (provider: AuthProvider) => provider.id === "claude-cli" || provider.id === "cursor-cli" || provider.id === "grok-cli" || provider.id === "llama-cpp"; + // FNXC:OmpAcp 2026-07-13-22:50: include omp-cli among supported CLI auth cards. + const isSupportedCliProvider = (provider: AuthProvider) => provider.id === "claude-cli" || provider.id === "cursor-cli" || provider.id === "grok-cli" || provider.id === "omp-cli" || provider.id === "llama-cpp"; /* FNXC:ProviderAuth 2026-07-02-12:20: Authentication ordering must sort supported CLI and non-CLI provider cards in one list so Cursor CLI or llama.cpp cannot split Claude CLI from Anthropic subscription/API-key entries. @@ -118,6 +120,9 @@ export function AuthenticationSection({ auth }: AuthenticationSectionProps) { if (provider.id === "grok-cli") { return (); } + if (provider.id === "omp-cli") { + return (); + } return (); }; const showAuthenticatedGroup = authenticatedProviders.length > 0; diff --git a/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx b/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx index c46b7f4672..72fcbbeb03 100644 --- a/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx +++ b/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx @@ -472,6 +472,8 @@ const NOT_SURFACED_ALLOWLIST: Record = { // Authentication section (POSTs /auth/grok-cli), not rendered as a plain description field. useGrokCli: "managed via GrokCliProviderCard in the Authentication section, not a plain description field", grokCliBinaryPath: "managed via GrokCliProviderCard in the Authentication section, not a plain description field", + useOmpCli: "managed via OmpCliProviderCard in the Authentication section, not a plain description field", + ompCliBinaryPath: "managed via OmpCliProviderCard in the Authentication section, not a plain description field", vitestAutoKillEnabled: "dashboard TUI memory guard, no Settings UI field", vitestKillThresholdPct: "dashboard TUI memory guard, no Settings UI field", agentMemoryInclusionMode: "not yet exposed as a distinct Settings field", diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 8645052b9a..f775f10d06 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -111,6 +111,7 @@ "@fusion-plugin-examples/compound-engineering": "workspace:*", "@fusion-plugin-examples/cursor-runtime": "workspace:*", "@fusion-plugin-examples/grok-runtime": "workspace:*", + "@fusion-plugin-examples/omp-runtime": "workspace:*", "@fusion-plugin-examples/dependency-graph": "workspace:*", "@fusion-plugin-examples/droid-runtime": "workspace:*", "@fusion-plugin-examples/hermes-runtime": "workspace:*", diff --git a/packages/dashboard/src/__tests__/routes-auth.test.ts b/packages/dashboard/src/__tests__/routes-auth.test.ts index 9e6ec45240..bb196cc3d5 100644 --- a/packages/dashboard/src/__tests__/routes-auth.test.ts +++ b/packages/dashboard/src/__tests__/routes-auth.test.ts @@ -904,6 +904,12 @@ describe("GET /auth/status", () => { reason: "mocked unavailable", probeDurationMs: 0, }); + vi.spyOn(runtimeProviderProbesModule, "probeOmpCliProvider").mockResolvedValue({ + available: false, + authenticated: false, + reason: "mocked unavailable", + probeDurationMs: 0, + }); vi.spyOn(llamaCppProbeModule, "probeLlamaCpp").mockResolvedValue({ available: false, reason: "mocked unavailable", @@ -945,7 +951,7 @@ describe("GET /auth/status", () => { expect(res.status).toBe(200); // Filter out synthetic CLI providers — they have dedicated route tests. // Structural assertions here are about OAuth + API-key paths only. - const providers = res.body.providers.filter((p: any) => p.id !== "claude-cli" && p.id !== "droid-cli" && p.id !== "cursor-cli" && p.id !== "grok-cli" && p.id !== "llama-cpp"); + const providers = res.body.providers.filter((p: any) => p.id !== "claude-cli" && p.id !== "droid-cli" && p.id !== "cursor-cli" && p.id !== "grok-cli" && p.id !== "omp-cli" && p.id !== "llama-cpp"); /* FN-7625: the static catalog (anthropic-subscription/github-copilot/openai-codex OAuth + the full API-key catalog) is always present, unioned with whatever the @@ -1062,7 +1068,7 @@ describe("GET /auth/status", () => { const res = await GET(app, "/api/auth/status"); expect(res.status).toBe(200); - const providers = res.body.providers.filter((p: any) => p.id !== "claude-cli" && p.id !== "droid-cli" && p.id !== "cursor-cli" && p.id !== "grok-cli" && p.id !== "llama-cpp"); + const providers = res.body.providers.filter((p: any) => p.id !== "claude-cli" && p.id !== "droid-cli" && p.id !== "cursor-cli" && p.id !== "grok-cli" && p.id !== "omp-cli" && p.id !== "llama-cpp"); /* FN-7625: catalog ids remain present even though storage only reported a narrow subset, and a storage-reported id NOT in the catalog ("acme-extension") @@ -1549,7 +1555,7 @@ describe("GET /auth/status", () => { function nonCliProviderIds(res: any): string[] { return res.body.providers - .filter((p: any) => p.id !== "claude-cli" && p.id !== "droid-cli" && p.id !== "cursor-cli" && p.id !== "grok-cli" && p.id !== "llama-cpp") + .filter((p: any) => p.id !== "claude-cli" && p.id !== "droid-cli" && p.id !== "cursor-cli" && p.id !== "grok-cli" && p.id !== "omp-cli" && p.id !== "llama-cpp") .map((p: any) => p.id); } @@ -1831,6 +1837,13 @@ describe("Droid CLI auth routes", () => { reason: "mocked unavailable", probeDurationMs: 0, }); + // FNXC:OmpAcp 2026-07-13-22:50: stub omp probe so /auth/status does not spawn real omp. + vi.spyOn(runtimeProviderProbesModule, "probeOmpCliProvider").mockResolvedValue({ + available: false, + authenticated: false, + reason: "mocked unavailable", + probeDurationMs: 0, + }); vi.spyOn(llamaCppProbeModule, "probeLlamaCpp").mockResolvedValue({ available: false, reason: "mocked unavailable", @@ -5411,4 +5424,67 @@ describe("llama.cpp auth routes", () => { expect(res.status).toBe(200); expect(onUseLlamaCppToggled).toHaveBeenCalledWith(false, true); }); + + it("POST /auth/omp-cli enables when omp binary is available", async () => { + vi.spyOn(runtimeProviderProbesModule, "probeOmpCliProvider").mockResolvedValue({ + available: true, + authenticated: true, + version: "omp/16.4.6", + probeDurationMs: 8, + }); + store.updateGlobalSettings = vi.fn().mockResolvedValue({ useOmpCli: true }); + + const res = await REQUEST(buildApp(), "POST", "/api/auth/omp-cli", JSON.stringify({ enabled: true }), { + "Content-Type": "application/json", + }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ enabled: true, restartRequired: false }); + expect(store.updateGlobalSettings).toHaveBeenCalledWith({ useOmpCli: true }); + }); + + it("POST /auth/omp-cli saves a validated binary path without toggling", async () => { + vi.spyOn(runtimeProviderProbesModule, "probeOmpCliProvider").mockResolvedValue({ + available: true, + authenticated: true, + version: "omp/16.4.6", + binaryPath: "/opt/omp", + configuredBinaryPath: "/opt/omp", + usingConfiguredBinaryPath: true, + probeDurationMs: 8, + }); + store.getGlobalSettingsStore = vi.fn().mockReturnValue({ + ...createMockGlobalSettingsStore(), + getSettings: vi.fn().mockResolvedValue({ useOmpCli: false }), + }); + store.updateGlobalSettings = vi.fn().mockResolvedValue({ useOmpCli: false, ompCliBinaryPath: "/opt/omp" }); + + const res = await REQUEST(buildApp(), "POST", "/api/auth/omp-cli", JSON.stringify({ binaryPath: " /opt/omp " }), { + "Content-Type": "application/json", + }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ enabled: false, binaryPath: "/opt/omp", restartRequired: false }); + expect(runtimeProviderProbesModule.probeOmpCliProvider).toHaveBeenCalledWith({ binaryPath: "/opt/omp" }); + expect(store.updateGlobalSettings).toHaveBeenCalledWith({ ompCliBinaryPath: "/opt/omp" }); + }); + + it("GET /providers/omp-cli/status returns ready when enabled and binary available", async () => { + vi.spyOn(runtimeProviderProbesModule, "probeOmpCliProvider").mockResolvedValue({ + available: true, + authenticated: true, + version: "omp/16.4.6", + probeDurationMs: 8, + }); + store.getGlobalSettingsStore = vi.fn().mockReturnValue({ + ...createMockGlobalSettingsStore(), + getSettings: vi.fn().mockResolvedValue({ useOmpCli: true }), + }); + + const res = await GET(buildApp(), "/api/providers/omp-cli/status"); + expect(res.status).toBe(200); + expect(res.body.ready).toBe(true); + expect(res.body.enabled).toBe(true); + }); + }); diff --git a/packages/dashboard/src/omp-model-cache.ts b/packages/dashboard/src/omp-model-cache.ts new file mode 100644 index 0000000000..f5f0dd05a2 --- /dev/null +++ b/packages/dashboard/src/omp-model-cache.ts @@ -0,0 +1,99 @@ +/** + * OMP CLI discovery → model-picker mapping, behind a short-TTL single-flight cache. + * + * FNXC:OmpAcp 2026-07-13-22:50: + * Mirrors grok-model-cache / cursor-model-cache. When useOmpCli is true, surface + * models from `omp models` under provider id `omp-cli`. Never throws; empty on failure. + */ + +import { discoverOmpCliModels } from "./runtime-provider-probes.js"; + +export interface OmpPickerModel { + provider: "omp-cli"; + id: string; + name: string; + reasoning: boolean; + contextWindow: number; +} + +export const OMP_PICKER_PROVIDER_ID = "omp-cli" as const; + +const DEFAULT_TTL_MS = 60_000; +const EMPTY_RESULT_TTL_MS = 5_000; + +export function ompDiscoveryToModels( + models: ReadonlyArray<{ id: string; label?: string }>, +): OmpPickerModel[] { + const seen = new Set(); + const result: OmpPickerModel[] = []; + for (const model of models) { + const id = model.id?.trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + result.push({ + provider: OMP_PICKER_PROVIDER_ID, + id, + name: model.label?.trim() || id, + reasoning: false, + contextWindow: 0, + }); + } + return result; +} + +interface CacheEntry { + fetchedAt: number; + models: OmpPickerModel[]; + ttlMs: number; +} + +const cache = new Map(); +const inFlight = new Map>(); + +export function __resetOmpPickerModelsCacheForTests(): void { + cache.clear(); + inFlight.clear(); +} + +export interface GetOmpPickerModelsOptions { + binaryPath?: string; + ttlMs?: number; + now?: () => number; +} + +export async function getOmpPickerModels( + opts?: GetOmpPickerModelsOptions, +): Promise { + const binaryPath = opts?.binaryPath ?? "omp"; + const ttlMs = opts?.ttlMs ?? DEFAULT_TTL_MS; + const now = opts?.now ?? Date.now; + const nowMs = now(); + + const cached = cache.get(binaryPath); + if (cached && nowMs - cached.fetchedAt < cached.ttlMs) { + return cached.models; + } + + const existingInFlight = inFlight.get(binaryPath); + if (existingInFlight) return existingInFlight; + + const fetchPromise = (async (): Promise => { + try { + const result = await discoverOmpCliModels({ binaryPath }); + if (!result || result.models.length === 0) return []; + return ompDiscoveryToModels(result.models); + } catch { + return []; + } + })(); + + inFlight.set(binaryPath, fetchPromise); + try { + const models = await fetchPromise; + const effectiveTtlMs = models.length === 0 ? EMPTY_RESULT_TTL_MS : ttlMs; + cache.set(binaryPath, { fetchedAt: now(), models, ttlMs: effectiveTtlMs }); + return models; + } finally { + inFlight.delete(binaryPath); + } +} diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 71fe19e3f0..3ef298fcbc 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -103,6 +103,7 @@ const BUNDLED_PLUGIN_IDS = new Set([ "fusion-plugin-paperclip-runtime", "fusion-plugin-cursor-runtime", "fusion-plugin-grok-runtime", + "fusion-plugin-omp-runtime", "fusion-plugin-cli-printing-press", "fusion-plugin-compound-engineering", ]); diff --git a/packages/dashboard/src/routes/register-auth-routes.ts b/packages/dashboard/src/routes/register-auth-routes.ts index 59476d9fb5..7bcfa8cb21 100644 --- a/packages/dashboard/src/routes/register-auth-routes.ts +++ b/packages/dashboard/src/routes/register-auth-routes.ts @@ -5,7 +5,7 @@ import { existsSync, readFileSync } from "node:fs"; import { GIT_INSTALL_URL, isGhAvailable, isGhAuthenticated, probeGitCliStatus } from "@fusion/core"; import { probeClaudeCli } from "../claude-cli-probe.js"; import { probeDroidCli } from "../droid-cli-probe.js"; -import { probeCursorCliProvider, probeGrokCliProvider } from "../runtime-provider-probes.js"; +import { probeCursorCliProvider, probeGrokCliProvider, probeOmpCliProvider } from "../runtime-provider-probes.js"; import { probeLlamaCpp } from "../llama-cpp-probe.js"; import { ApiError, badRequest, conflict } from "../api-error.js"; import { clearUsageCache } from "../usage.js"; @@ -77,6 +77,24 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { return probeGrokCliProvider({ binaryPath: await readGrokCliBinaryPath() }); } + /* + FNXC:OmpAcp 2026-07-13-22:50: + Mirrors Grok/Cursor binary path helpers so auth provider list, status, enable, and path-save validation probe the same trimmed global OMP CLI override. + */ + function normalizeOmpCliBinaryPath(value: unknown): string | undefined { + return typeof value === "string" ? value.trim() || undefined : undefined; + } + + async function readOmpCliBinaryPath(): Promise { + if (!store) return undefined; + const globalSettings = await store.getGlobalSettingsStore().getSettings(); + return normalizeOmpCliBinaryPath((globalSettings as Record).ompCliBinaryPath); + } + + async function probeOmpCliWithStoredBinary() { + return probeOmpCliProvider({ binaryPath: await readOmpCliBinaryPath() }); + } + /** * Mask an API key for safe display. * - If key length <= 8: return 8 bullets (never reveal short keys) @@ -713,6 +731,27 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { }); } + /* + FNXC:OmpAcp 2026-07-13-22:50: + Inject synthetic "Oh My Pi — via omp ACP" provider. authenticated = toggle + binary available; omp owns credentials under ~/.omp. + */ + if (store) { + let ompEnabled = false; + try { + const globalSettings = await store.getGlobalSettingsStore().getSettings(); + ompEnabled = (globalSettings as Record).useOmpCli === true; + } catch { + // best effort + } + const ompBinary = await probeOmpCliWithStoredBinary(); + providers.push({ + id: "omp-cli", + name: "Oh My Pi — via omp ACP", + authenticated: ompEnabled && ompBinary.available, + type: "cli" as const, + }); + } + // Inject synthetic llama.cpp provider. if (store) { let llamaEnabled = false; @@ -1181,6 +1220,90 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { } }); + /* + FNXC:OmpAcp 2026-07-13-22:50: + POST /auth/omp-cli mirrors Grok/Cursor enable/disable + binaryPath contract. Enable requires binary available; omp auth stays under ~/.omp. + */ + router.post("/auth/omp-cli", async (req, res) => { + try { + if (!store) { + throw new ApiError(500, "Settings store unavailable"); + } + const requestedEnabled = req.body?.enabled; + const hasEnabledPatch = Object.prototype.hasOwnProperty.call(req.body ?? {}, "enabled"); + const requestedBinaryPath = req.body?.binaryPath; + const hasBinaryPathPatch = Object.prototype.hasOwnProperty.call(req.body ?? {}, "binaryPath"); + if (!hasEnabledPatch && !hasBinaryPathPatch) { + throw badRequest("enabled or binaryPath is required"); + } + if (hasEnabledPatch && typeof requestedEnabled !== "boolean") { + throw badRequest("enabled must be a boolean"); + } + if (hasBinaryPathPatch && requestedBinaryPath !== null && typeof requestedBinaryPath !== "string") { + throw badRequest("binaryPath must be a string or null"); + } + + const currentSettings = await store.getGlobalSettingsStore().getSettings(); + const enabled = hasEnabledPatch ? requestedEnabled : (currentSettings as Record).useOmpCli === true; + const currentBinaryPath = normalizeOmpCliBinaryPath((currentSettings as Record).ompCliBinaryPath); + const nextBinaryPath = hasBinaryPathPatch + ? normalizeOmpCliBinaryPath(requestedBinaryPath) + : currentBinaryPath; + + if (hasBinaryPathPatch && nextBinaryPath) { + const binary = await probeOmpCliProvider({ binaryPath: nextBinaryPath }); + if (!binary.available || !binary.usingConfiguredBinaryPath) { + throw new ApiError(400, `Cannot save OMP CLI binary path: ${binary.reason ?? "configured binary not available"}`); + } + } + + if (enabled) { + const binary = await probeOmpCliProvider({ binaryPath: nextBinaryPath }); + if (!binary.available) { + throw new ApiError(400, `Cannot enable OMP CLI routing: ${binary.reason ?? "omp binary not available"}`); + } + } + + const patch: Record = {}; + if (hasEnabledPatch) { + patch.useOmpCli = enabled; + } + if (hasBinaryPathPatch) { + patch.ompCliBinaryPath = nextBinaryPath ?? null; + } + const settings = await store.updateGlobalSettings(patch); + invalidateAllGlobalSettingsCaches(); + res.json({ + enabled: (settings as Record).useOmpCli === true, + binaryPath: normalizeOmpCliBinaryPath((settings as Record).ompCliBinaryPath), + restartRequired: false, + }); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + rethrowAsApiError(err); + } + }); + + router.get("/providers/omp-cli/status", async (_req, res) => { + try { + const binaryPath = await readOmpCliBinaryPath(); + const binary = await probeOmpCliProvider({ binaryPath }); + let enabled = false; + if (store) { + try { + const globalSettings = await store.getGlobalSettingsStore().getSettings(); + enabled = (globalSettings as Record).useOmpCli === true; + } catch { + // best effort + } + } + res.json({ binary, enabled, binaryPath, extension: null, ready: enabled && binary.available }); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + rethrowAsApiError(err); + } + }); + router.post("/auth/llama-cpp", async (req, res) => { try { if (!store) { diff --git a/packages/dashboard/src/routes/register-model-routes.ts b/packages/dashboard/src/routes/register-model-routes.ts index ce89d6a773..61bcf1b0c8 100644 --- a/packages/dashboard/src/routes/register-model-routes.ts +++ b/packages/dashboard/src/routes/register-model-routes.ts @@ -6,6 +6,7 @@ import type { CustomProvider } from "@fusion/core"; import { ApiError } from "../api-error.js"; import { getCursorPickerModels, CURSOR_PICKER_PROVIDER_ID } from "../cursor-model-cache.js"; import { getGrokPickerModels, GROK_PICKER_PROVIDER_ID } from "../grok-model-cache.js"; +import { getOmpPickerModels, OMP_PICKER_PROVIDER_ID } from "../omp-model-cache.js"; import { getHermesPickerModels, HERMES_PICKER_PROVIDER_ID } from "../hermes-model-cache.js"; import type { AuthStorageLike } from "../routes.js"; import type { ApiRouteRegistrar } from "./types.js"; @@ -166,6 +167,8 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => { let cursorCliBinaryPath: string | undefined; let useGrokCli = false; let grokCliBinaryPath: string | undefined; + let useOmpCli = false; + let ompCliBinaryPath: string | undefined; let resolvedPlanningProvider: string | undefined; let resolvedPlanningModelId: string | undefined; let customProviders: CustomProvider[] = []; @@ -205,6 +208,14 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => { const rawGrokCliBinaryPath = (globalSettings as Record).grokCliBinaryPath; grokCliBinaryPath = typeof rawGrokCliBinaryPath === "string" ? rawGrokCliBinaryPath.trim() || undefined : undefined; + /* + FNXC:OmpAcp 2026-07-13-22:50: + useOmpCli toggle + ompCliBinaryPath override for model-picker discovery (mirrors Grok). + */ + useOmpCli = (globalSettings as Record).useOmpCli === true; + const rawOmpCliBinaryPath = (globalSettings as Record).ompCliBinaryPath; + ompCliBinaryPath = + typeof rawOmpCliBinaryPath === "string" ? rawOmpCliBinaryPath.trim() || undefined : undefined; customProviders = globalSettings.customProviders ?? []; const mergedSettings = await store.getSettingsFast(); @@ -295,6 +306,9 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => { if (!useGrokCli) { models = models.filter((m) => m.provider !== "grok-cli"); } + if (!useOmpCli) { + models = models.filter((m) => m.provider !== "omp-cli"); + } /* FNXC:ModelCatalog 2026-07-07-09:05: @@ -399,6 +413,25 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => { } } + /* + FNXC:OmpAcp 2026-07-13-22:50: + Surface omp models under omp-cli when useOmpCli is on (additive; never displace existing rows). + */ + if (useOmpCli) { + try { + const ompModels = await getOmpPickerModels({ binaryPath: ompCliBinaryPath }); + for (const ompModel of ompModels) { + const key = `${ompModel.provider}/${ompModel.id}`; + if (seenModelKeys.has(key)) continue; + seenModelKeys.add(key); + models.push(ompModel); + } + } catch (ompErr: unknown) { + const message = ompErr instanceof Error ? ompErr.message : String(ompErr); + runtimeLogger.child("models").warn(`Failed to load omp-cli models: ${message}`); + } + } + // Filter to only providers the user has explicitly configured in Fusion. // getAvailable() checks supplemental credential stores (Codex CLI, // Claude Code, env vars) which surface providers the user may not @@ -433,6 +466,8 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => { // FNXC:GrokCli 2026-07-08-00:05 (FN-7705): allow-list "grok-cli" through // the final filter whenever the toggle is on, mirroring cursor-cli above. if (useGrokCli) configuredProviders.add(GROK_PICKER_PROVIDER_ID); + // FNXC:OmpAcp 2026-07-13-22:50: allow-list omp-cli when toggle is on. + if (useOmpCli) configuredProviders.add(OMP_PICKER_PROVIDER_ID); // FNXC:ModelCatalog 2026-07-07-09:05 (FN-7636): only allow-list "hermes" // through the final filter when Hermes rows were actually contributed // above, mirroring the useClaudeCli/useDroidCli toggle pattern (Hermes diff --git a/packages/dashboard/src/runtime-provider-probes.ts b/packages/dashboard/src/runtime-provider-probes.ts index 83e55f6fa5..45a37deb62 100644 --- a/packages/dashboard/src/runtime-provider-probes.ts +++ b/packages/dashboard/src/runtime-provider-probes.ts @@ -36,6 +36,16 @@ import { type GrokBinaryStatus, } from "@fusion-plugin-examples/grok-runtime"; +/* +FNXC:OmpAcp 2026-07-13-22:50: +Oh My Pi (omp) ACP runtime probe façade — same boundary as Grok/Cursor so route handlers and tests mock here without importing the plugin package directly. +*/ +import { + discoverOmpProviderModels, + probeOmpBinary, + type OmpBinaryStatus, +} from "@fusion-plugin-examples/omp-runtime"; + import { agentsMe, discoverPaperclipCliConfig, @@ -62,6 +72,7 @@ export type { OpenClawBinaryStatus, CursorBinaryStatus, GrokBinaryStatus, + OmpBinaryStatus, PaperclipAgentSummary, PaperclipCliDiscoveryResult, PaperclipCompanySummary, @@ -77,6 +88,19 @@ export async function probeGrokCliProvider(opts?: { binaryPath?: string }): Prom return probeGrokBinary(opts); } +/* +FNXC:OmpAcp 2026-07-11-23:35: +Oh My Pi (omp) ACP runtime probe façade — same boundary pattern as Grok/Cursor so +route handlers and tests mock here without importing the plugin package directly. +*/ +export async function probeOmpCliProvider(opts?: { binaryPath?: string }): Promise { + return probeOmpBinary(opts); +} + +export async function discoverOmpCliModels(opts?: { binaryPath?: string; timeoutMs?: number }) { + return discoverOmpProviderModels(opts); +} + /** * Result shape returned by the Cursor plugin's model-discovery contribution. * diff --git a/packages/dashboard/vitest.config.ts b/packages/dashboard/vitest.config.ts index ee73610922..04678bac12 100644 --- a/packages/dashboard/vitest.config.ts +++ b/packages/dashboard/vitest.config.ts @@ -541,6 +541,19 @@ export default defineConfig({ __dirname, "../../plugins/fusion-plugin-grok-runtime/src/index.ts", ), + /* + FNXC:OmpAcp 2026-07-11-23:35: + runtime-provider-probes.ts imports probeOmpBinary from @fusion-plugin-examples/omp-runtime. + Source aliases avoid missing dist/ on a source checkout. + */ + "@fusion-plugin-examples/omp-runtime/probe": resolve( + __dirname, + "../../plugins/fusion-plugin-omp-runtime/src/probe.ts", + ), + "@fusion-plugin-examples/omp-runtime": resolve( + __dirname, + "../../plugins/fusion-plugin-omp-runtime/src/index.ts", + ), "@fusion-plugin-examples/roadmap/roadmap-suggestions": resolve( __dirname, "../../plugins/fusion-plugin-roadmap/src/roadmap-suggestions.ts", diff --git a/packages/engine/src/__tests__/mcp-runtime-support.test.ts b/packages/engine/src/__tests__/mcp-runtime-support.test.ts index dad2d3b3d7..3a0d2777b8 100644 --- a/packages/engine/src/__tests__/mcp-runtime-support.test.ts +++ b/packages/engine/src/__tests__/mcp-runtime-support.test.ts @@ -10,6 +10,8 @@ describe("runtimeSupportsMcp", () => { it("allows Claude/ACP runtime identifiers", () => { expect(runtimeSupportsMcp("claude-code", "anthropic")).toBe(true); expect(runtimeSupportsMcp("vendor-acp-runtime", "anthropic")).toBe(true); + // FNXC:OmpAcp 2026-07-11-23:35: Oh My Pi ACP runtime forwards mcpServers on session/new. + expect(runtimeSupportsMcp("omp", "anthropic")).toBe(true); }); it("rejects mock provider even on an otherwise supported runtime", () => { diff --git a/packages/engine/src/mcp-runtime-support.ts b/packages/engine/src/mcp-runtime-support.ts index bf353d6975..ad4dc7651a 100644 --- a/packages/engine/src/mcp-runtime-support.ts +++ b/packages/engine/src/mcp-runtime-support.ts @@ -2,7 +2,21 @@ import { MOCK_PROVIDER_ID } from "@fusion/core"; import { createLogger } from "./logger.js"; const mcpRuntimeLog = createLogger("mcp-runtime"); -const SUPPORTED_RUNTIME_IDS = new Set(["pi", "default-pi", "claude", "claude-code", "claude-acp", "acp"]); +/* +FNXC:OmpAcp 2026-07-11-23:35: +`omp` is the Oh My Pi ACP runtime (fusion-plugin-omp-runtime). It speaks +session/new.mcpServers like other ACP agents, so operator MCP must be eligible +for forwarding when that runtime is selected. +*/ +const SUPPORTED_RUNTIME_IDS = new Set([ + "pi", + "default-pi", + "claude", + "claude-code", + "claude-acp", + "acp", + "omp", +]); function normalizeId(value: string | undefined): string { return value?.trim().toLowerCase() ?? ""; diff --git a/plugins/fusion-plugin-omp-runtime/CHANGELOG.md b/plugins/fusion-plugin-omp-runtime/CHANGELOG.md new file mode 100644 index 0000000000..d15f301404 --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/CHANGELOG.md @@ -0,0 +1,11 @@ +# @fusion-plugin-examples/omp-runtime + +## 0.1.0 + +### Minor Changes + +- Initial OMP (Oh My Pi) runtime plugin over ACP (`omp acp`). + - Runtime id `omp`, CLI provider `omp-cli` + - Vendored ACP client (JSON-RPC/stdio) + - Probe via `omp --version`; optional model list via `omp models` + - Auth prefers omp `agent` method (reuses `~/.omp`) diff --git a/plugins/fusion-plugin-omp-runtime/README.md b/plugins/fusion-plugin-omp-runtime/README.md new file mode 100644 index 0000000000..8d537f539c --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/README.md @@ -0,0 +1,104 @@ +# fusion-plugin-omp-runtime + +Oh My Pi (`omp`) CLI-backed provider/runtime plugin for Fusion. Agent sessions use **native ACP** (`omp acp`) for realtime streaming, tool visibility, and multi-turn reuse. + +## Install + +This plugin is staged with Fusion runtime plugins. It shells out to an **operator-installed** `omp` binary on PATH — Fusion never downloads or bundles the CLI itself. + +### External integration evidence + +Per `AGENTS.md` (External-integration evidence): + +- **Canonical upstream repo URL:** https://github.com/can1357/oh-my-pi +- **Docs / homepage URL:** https://omp.sh/ · https://omp.sh/docs/acp +- **Release / download URL:** https://github.com/can1357/oh-my-pi/releases · installer `curl -fsSL https://raw.githubusercontent.com/can1357/oh-my-pi/main/scripts/install.sh | sh` · npm `@oh-my-pi/pi-coding-agent` +- **Binary / CLI name:** `omp` +- **Checksum:** `upstream-pending-verification` (operator-installed binary; Fusion does not pin/download it) +- **ACP protocol:** https://agentclientprotocol.com · https://github.com/zed-industries/agent-client-protocol +- **ACP TypeScript SDK:** `@agentclientprotocol/sdk` `0.24.0` + +## Contract summary + +| Item | Value | +| --- | --- | +| Plugin ID | `fusion-plugin-omp-runtime` | +| Runtime ID | `omp` | +| Provider ID | `omp-cli` | +| Binary probe | `omp --version` | +| ACP launch | `omp acp` (equiv. `omp --mode acp`) | +| Optional model | `omp --model acp` | +| Auth | omp `agent` method — reuses `~/.omp` provider keys / OAuth | + +## Agent session path — ACP (primary) + +`OmpRuntimeAdapter` drives omp’s native ACP server with a **vendored** ACP client (copied under `src/acp/`, not imported from `fusion-plugin-acp-runtime`): + +```bash +omp acp +# optional model: +omp --model claude-sonnet-4 acp +``` + +- **Realtime streaming** — ACP `session/update` notifications map to Fusion `onText` / `onThinking` / `onToolStart` / `onToolEnd`. +- **Multi-turn** — one `createSession` keeps the ACP connection; each `promptWithFallback` is a `session/prompt` on the same session. +- **Permissions** — omp tool calls surface as `session/request_permission` and go through Fusion’s per-category action gate. +- **Auth** — after `initialize`, Fusion prefers the `agent` auth method (credentials under `~/.omp`). Terminal/TUI login is not preferred for headless Fusion. +- **Env** — subprocess env is allow-listed (`HOME`/`PATH`/XDG + common provider key names). Full `process.env` is never inherited. + +See https://omp.sh/docs/acp for the upstream protocol surface (slash commands, `_omp/*` extensions, wire debugging). + +## Enable in Fusion + +1. Install & authenticate `omp` (`omp --version`; credentials under `~/.omp`). +2. Install/enable this plugin (staged bundled runtime). +3. **Settings → Authentication → Oh My Pi — via omp ACP** → Enable (optional binary path). +4. Either: + - Agent → **Runtime Source: Runtime** → **OMP Runtime** (`runtimeHint: "omp"`), or + - Pick an `omp-cli/*` model when the toggle is on (from `omp models`). + +## Fusion tools (`fn_*`) + +Engine `customTools` (board/task/agent tools such as `fn_task_list`) are exposed to +omp as MCP server **`fusion-custom-tools`**: + +1. `OmpRuntimeAdapter` starts a **loopback HTTP bridge** holding the in-process + `ToolDefinition.execute` closures. +2. Session `session/new.mcpServers` includes a stdio MCP child + (`mcp-schema-server.cjs`) that implements `tools/list` + `tools/call` and + POSTs calls to the bridge (`FUSION_OMP_TOOL_BRIDGE_URL`). +3. Operator-configured MCP servers are forwarded alongside (stdio/http/sse). +4. System rules tell omp to prefer `fusion-custom-tools` for Fusion board ops. + +## Known v1 gaps + +| Gap | Status | +| --- | --- | +| Auto-route `omp-cli/*` without explicit Runtime Source | **Partial** — models surface when Enable is on; prefer `runtimeHint: "omp"` for agent lanes | +| Mid-session Fusion model picker changes | **Not wired** — model is fixed at `omp --model … acp` spawn | + +## Settings (ACP spawn bag) + +Built internally by `buildOmpAcpRuntimeSettings`: + +| Key | Default | Meaning | +| --- | --- | --- | +| `acpBinaryPath` | `omp` | Agent binary | +| `acpArgs` | `["acp"]` | ACP mode args | +| `acpModel` | `omp/default` | Model id for describeModel / optional `--model` | +| `acpEnvAllowList` | see `OMP_ACP_ENV_ALLOWLIST` | Env names forwarded | +| `acpFsRead` / `acpFsWrite` | `false` | Client-side ACP fs capabilities (opt-in) | +| `acpAllowUnrestricted` | `true` | Operator-selected first-party CLI posture | +| `acpAuthenticate.preferMethods` | `["agent","terminal"]` | Post-initialize auth | + +## Development + +```bash +pnpm --filter @fusion-plugin-examples/omp-runtime test +pnpm --filter @fusion-plugin-examples/omp-runtime build +``` + +## Notes + +- Do not invent release checksums for the operator’s `omp` binary. +- Generic multi-agent ACP (any binary) remains `fusion-plugin-acp-runtime`; this plugin is the omp-specific first-class path with sane defaults. diff --git a/plugins/fusion-plugin-omp-runtime/manifest.json b/plugins/fusion-plugin-omp-runtime/manifest.json new file mode 100644 index 0000000000..277493e600 --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/manifest.json @@ -0,0 +1,6 @@ +{ + "id": "fusion-plugin-omp-runtime", + "name": "OMP Runtime Plugin", + "version": "0.1.0", + "description": "Provides Oh My Pi (omp) CLI-backed agent runtime integration over ACP (omp acp)" +} diff --git a/plugins/fusion-plugin-omp-runtime/package.json b/plugins/fusion-plugin-omp-runtime/package.json new file mode 100644 index 0000000000..e9f4f4d622 --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/package.json @@ -0,0 +1,46 @@ +{ + "name": "@fusion-plugin-examples/omp-runtime", + "version": "0.1.0", + "type": "module", + "description": "Oh My Pi (omp) runtime plugin for Fusion via Agent Client Protocol (omp acp)", + "keywords": [ + "fusion-plugin", + "omp", + "oh-my-pi", + "acp", + "agent-client-protocol", + "runtime" + ], + "exports": { + ".": { + "types": "./src/index.ts", + "source": "./src/index.ts", + "import": "./dist/index.js" + }, + "./probe": { + "types": "./src/probe.ts", + "source": "./src/probe.ts", + "import": "./dist/probe.js" + } + }, + "private": true, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest run --silent=passed-only --reporter=dot" + }, + "dependencies": { + "@agentclientprotocol/sdk": "0.24.0", + "@fusion/core": "workspace:*", + "@fusion/plugin-sdk": "workspace:*" + }, + "peerDependencies": { + "@earendil-works/pi-ai": "*", + "@earendil-works/pi-coding-agent": "*" + }, + "devDependencies": { + "@types/node": "^25.5.2", + "typescript": "^5.7.0", + "vitest": "^4.1.0" + } +} diff --git a/plugins/fusion-plugin-omp-runtime/src/__tests__/acp-settings.test.ts b/plugins/fusion-plugin-omp-runtime/src/__tests__/acp-settings.test.ts new file mode 100644 index 0000000000..b6eb42b387 --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/src/__tests__/acp-settings.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { + buildOmpAcpArgs, + buildOmpAcpRuntimeSettings, + OMP_ACP_ENV_ALLOWLIST, + modelForCli, + normalizeOmpCliModel, + resolveOmpAcpAuthPreferMethods, +} from "../acp-settings.js"; + +describe("acp-settings", () => { + it("builds omp acp args without --model when model is absent", () => { + expect(buildOmpAcpArgs()).toEqual(["acp"]); + expect(buildOmpAcpArgs({})).toEqual(["acp"]); + }); + + it("places --model before the acp mode", () => { + expect(buildOmpAcpArgs({ model: "claude-sonnet-4" })).toEqual([ + "--model", + "claude-sonnet-4", + "acp", + ]); + }); + + it("prefers agent auth over terminal", () => { + expect(resolveOmpAcpAuthPreferMethods()).toEqual(["agent", "terminal"]); + }); + + it("normalizes provider-qualified model ids", () => { + expect(normalizeOmpCliModel("omp-cli/claude-sonnet-4")).toBe("claude-sonnet-4"); + expect(normalizeOmpCliModel("omp/claude-sonnet-4")).toBe("claude-sonnet-4"); + expect(normalizeOmpCliModel("claude-sonnet-4")).toBe("claude-sonnet-4"); + expect(normalizeOmpCliModel(undefined)).toBeUndefined(); + }); + + it("omits --model for the omp/default fallback", () => { + expect(modelForCli("omp/default")).toBeUndefined(); + expect(modelForCli("default")).toBeUndefined(); + expect(modelForCli("omp-cli/claude-sonnet-4")).toBe("claude-sonnet-4"); + }); + + it("builds AcpRuntimeAdapter settings for OMP ACP", () => { + const settings = buildOmpAcpRuntimeSettings({ + binary: "/usr/local/bin/omp", + model: "omp-cli/claude-sonnet-4", + }); + expect(settings.acpBinaryPath).toBe("/usr/local/bin/omp"); + expect(settings.acpArgs).toEqual(["--model", "claude-sonnet-4", "acp"]); + expect(settings.acpEnvAllowList).toEqual([...OMP_ACP_ENV_ALLOWLIST]); + expect(settings.acpFsRead).toBe(false); + expect(settings.acpFsWrite).toBe(false); + expect(settings.acpAllowUnrestricted).toBe(true); + expect(settings.acpEnvAllowList).toEqual(expect.arrayContaining(["HOME", "PATH"])); + expect(settings.acpAuthenticate).toEqual( + expect.objectContaining({ + preferMethods: ["agent", "terminal"], + meta: { headless: true }, + require: false, + }), + ); + }); +}); diff --git a/plugins/fusion-plugin-omp-runtime/src/__tests__/index.test.ts b/plugins/fusion-plugin-omp-runtime/src/__tests__/index.test.ts new file mode 100644 index 0000000000..8c78b66532 --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/src/__tests__/index.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import plugin from "../index.js"; + +describe("omp plugin export", () => { + it("declares omp-cli provider contribution and omp runtime", () => { + expect(plugin.manifest.id).toBe("fusion-plugin-omp-runtime"); + expect(plugin.manifest.runtime?.runtimeId).toBe("omp"); + expect(plugin.runtime?.metadata.runtimeId).toBe("omp"); + expect(plugin.cliProviders?.[0]?.providerId).toBe("omp-cli"); + expect(plugin.cliProviders?.[0]?.statusRoute).toBe("/providers/omp-cli/status"); + expect(plugin.cliProviders?.[0]?.authRoute).toBe("/auth/omp-cli"); + expect(plugin.cliProviders?.[0]?.binaryName).toBe("omp"); + expect(plugin.cliProviders?.[0]?.runtime?.runtimeId).toBe("omp"); + }); +}); diff --git a/plugins/fusion-plugin-omp-runtime/src/__tests__/mcp-forwarding.test.ts b/plugins/fusion-plugin-omp-runtime/src/__tests__/mcp-forwarding.test.ts new file mode 100644 index 0000000000..f7b9ba5811 --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/src/__tests__/mcp-forwarding.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { toAcpMcpServers } from "../mcp-forwarding.js"; + +describe("toAcpMcpServers", () => { + it("maps stdio, http, and sse transports", () => { + const servers = toAcpMcpServers([ + { + name: "local", + transport: "stdio", + command: "node", + args: ["server.js"], + env: { TOKEN: "x" }, + }, + { + name: "remote", + transport: "http", + url: "https://example.com/mcp", + headers: { Authorization: "Bearer t" }, + }, + { + name: "events", + type: "sse", + url: "https://example.com/sse", + }, + { name: "disabled", enabled: false, command: "noop" }, + ]); + + expect(servers).toEqual([ + { + name: "local", + command: "node", + args: ["server.js"], + env: [{ name: "TOKEN", value: "x" }], + }, + { + type: "http", + name: "remote", + url: "https://example.com/mcp", + headers: [{ name: "Authorization", value: "Bearer t" }], + }, + { + type: "sse", + name: "events", + url: "https://example.com/sse", + headers: [], + }, + ]); + }); + + it("returns empty for non-arrays", () => { + expect(toAcpMcpServers(undefined)).toEqual([]); + expect(toAcpMcpServers(null)).toEqual([]); + }); +}); diff --git a/plugins/fusion-plugin-omp-runtime/src/__tests__/omp-acp-live.test.ts b/plugins/fusion-plugin-omp-runtime/src/__tests__/omp-acp-live.test.ts new file mode 100644 index 0000000000..aeba9c9c4e --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/src/__tests__/omp-acp-live.test.ts @@ -0,0 +1,97 @@ +/** + * Live smoke against an operator-installed `omp` binary. + * Skips automatically when `omp` is not on PATH (CI without omp). + * + * FNXC:OmpAcp 2026-07-11-23:45: + * Verify the plugin's ACP client can initialize + open a session against real + * `omp acp` (not the echo fixture). Handshake + session/new is the acceptance bar. + * + * FNXC:OmpAcp 2026-07-13-22:50: + * Probe at module load so it.skipIf sees a stable flag (beforeAll is too late for skipIf). + */ +import { afterEach, describe, expect, it } from "vitest"; +import { connect, killAllProcesses, newAcpSession } from "../acp/index.js"; +import { buildOmpAcpArgs, buildOmpAcpRuntimeSettings, OMP_ACP_ENV_ALLOWLIST } from "../acp-settings.js"; +import { buildSpawnEnv } from "../acp/process-manager.js"; +import { probeOmpBinary } from "../probe.js"; +import { OmpRuntimeAdapter } from "../runtime-adapter.js"; + +const ompProbe = await probeOmpBinary({ timeoutMs: 5000 }); +const ompAvailable = ompProbe.available; +const ompBinary = ompProbe.binaryPath ?? ompProbe.binaryName ?? "omp"; + +afterEach(() => { + killAllProcesses(); +}); + +describe("omp acp live", () => { + it("probes the local omp binary", async () => { + const status = await probeOmpBinary({ timeoutMs: 5000 }); + if (!status.available) { + expect(status.available).toBe(false); + return; + } + expect(status.authenticated).toBe(true); + expect(status.version).toMatch(/omp|pi|\d/i); + }); + + it.skipIf(!ompAvailable)("handshakes initialize + session/new against omp acp", async () => { + const settings = buildOmpAcpRuntimeSettings({ binary: ompBinary }); + const env = buildSpawnEnv([...OMP_ACP_ENV_ALLOWLIST], { required: [] }); + + const connection = await connect({ + binaryPath: ompBinary, + args: buildOmpAcpArgs(), + cwd: process.cwd(), + env, + advertiseFs: { read: false, write: false }, + initializeTimeoutMs: 30_000, + authenticate: settings.acpAuthenticate as { + preferMethods?: string[]; + methodId?: string; + meta?: Record; + require?: boolean; + }, + }); + + try { + expect(connection.child.pid).toBeGreaterThan(0); + expect(connection.conn).toBeDefined(); + expect(Array.isArray(connection.authMethods)).toBe(true); + + const session = await newAcpSession(connection, { + cwd: process.cwd(), + mcpServers: [], + }); + expect(session.sessionId).toBeTruthy(); + expect(typeof session.sessionId).toBe("string"); + } finally { + connection.dispose(); + } + }, 60_000); + + it.skipIf(!ompAvailable)("OmpRuntimeAdapter createSession opens a live connection", async () => { + const texts: string[] = []; + const adapter = new OmpRuntimeAdapter({ binary: ompBinary }); + const { session } = await adapter.createSession({ + cwd: process.cwd(), + systemPrompt: "You are a test harness. Reply briefly.", + onText: (t) => texts.push(t), + }); + + try { + expect(session.connection).toBeTruthy(); + expect(session.sessionId).toBeTruthy(); + expect(adapter.describeModel(session)).toMatch(/^omp\//); + + const result = await adapter.promptWithFallback(session, "Reply with exactly: OK"); + const combined = texts.join(""); + const hasOutput = combined.trim().length > 0; + const hasStop = + result && typeof result === "object" && "stopReason" in result && Boolean(result.stopReason); + expect(hasOutput || hasStop || Boolean(session.state.errorMessage)).toBe(true); + } finally { + await adapter.dispose(session); + } + }, 120_000); +}); diff --git a/plugins/fusion-plugin-omp-runtime/src/__tests__/probe.test.ts b/plugins/fusion-plugin-omp-runtime/src/__tests__/probe.test.ts new file mode 100644 index 0000000000..55d7c66c07 --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/src/__tests__/probe.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { probeOmpBinary } from "../probe.js"; + +describe("probeOmpBinary", () => { + it("tries configured path first, then PATH fallback", async () => { + const missing = "/nonexistent/omp-binary-that-does-not-exist"; + const status = await probeOmpBinary({ + binaryPath: missing, + timeoutMs: 500, + }); + expect(status.configuredBinaryPath).toBe(missing); + expect(status.probeDurationMs).toBeGreaterThanOrEqual(0); + // Diagnostics should mention the configured path failure even if PATH omp succeeds. + expect(status.diagnostics?.some((d) => d.includes("nonexistent"))).toBe(true); + + if (status.available) { + // Machine has `omp` on PATH — fallback is intentional (mirrors Grok probe). + expect(status.usingConfiguredBinaryPath).toBe(false); + expect(status.authenticated).toBe(true); + } else { + expect(status.authenticated).toBe(false); + expect(status.reason).toMatch(/failed|not found/i); + } + }); +}); diff --git a/plugins/fusion-plugin-omp-runtime/src/__tests__/runtime-adapter.test.ts b/plugins/fusion-plugin-omp-runtime/src/__tests__/runtime-adapter.test.ts new file mode 100644 index 0000000000..c8e80c26dc --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/src/__tests__/runtime-adapter.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it, vi } from "vitest"; +import { OmpRuntimeAdapter } from "../runtime-adapter.js"; +import type { AgentRuntimeOptions, AgentSession, AgentSessionResult } from "../types.js"; + +function makeFakeSession(partial?: Partial): AgentSession { + const messages: unknown[] = []; + return { + model: "omp/default", + messages, + state: { messages }, + lastModelDescription: "omp/default", + callbacks: {}, + connection: { live: true }, + dispose: () => undefined, + ...partial, + }; +} + +describe("OmpRuntimeAdapter", () => { + it("forwards Fusion systemPrompt via sessionMeta.systemPromptOverride", async () => { + let seenOptions: AgentRuntimeOptions | undefined; + const liveSession = makeFakeSession(); + const adapter = new OmpRuntimeAdapter({ + createAcpAdapter: () => ({ + createSession: async (options) => { + seenOptions = options; + return { session: liveSession }; + }, + promptWithFallback: async () => undefined, + describeModel: () => "omp/default", + }), + }); + + await adapter.createSession({ + cwd: process.cwd(), + systemPrompt: "Fusion system context", + }); + + expect(seenOptions?.sessionMeta).toEqual( + expect.objectContaining({ + systemPromptOverride: expect.stringContaining("Fusion system context"), + }), + ); + }); + + it("forwards Fusion fn_* tools via fusion-custom-tools MCP server", async () => { + let seenOptions: AgentRuntimeOptions | undefined; + const liveSession = makeFakeSession(); + const adapter = new OmpRuntimeAdapter({ + createAcpAdapter: () => ({ + createSession: async (options) => { + seenOptions = options; + return { session: liveSession }; + }, + promptWithFallback: async () => undefined, + describeModel: () => "omp/default", + }), + }); + + const { session } = await adapter.createSession({ + cwd: process.cwd(), + systemPrompt: "sys", + customTools: [ + { + name: "fn_task_list", + description: "List tasks", + parameters: { type: "object", properties: {} }, + execute: async () => ({ text: "ok" }), + }, + ], + }); + + try { + const mcp = seenOptions?.mcpServers as Array<{ name?: string }> | undefined; + expect(mcp?.some((s) => s.name === "fusion-custom-tools")).toBe(true); + expect(String(seenOptions?.sessionMeta?.systemPromptOverride ?? "")).toContain( + "fusion-custom-tools", + ); + } finally { + await adapter.dispose(session); + } + }); + + it("surfaces create failures as onText diagnostics without throwing", async () => { + const onText = vi.fn(); + const adapter = new OmpRuntimeAdapter({ + createAcpAdapter: () => ({ + createSession: async () => { + throw new Error("spawn ENOENT"); + }, + promptWithFallback: async () => undefined, + describeModel: () => "omp/default", + }), + }); + + const { session } = await adapter.createSession({ + cwd: process.cwd(), + systemPrompt: "", + onText, + }); + + expect(onText).toHaveBeenCalled(); + expect(String(onText.mock.calls[0]?.[0])).toMatch(/OMP ACP failed to start/); + expect(session.state.errorMessage).toMatch(/OMP ACP failed to start/); + }); + + it("forwards prompts to the ACP adapter when connection is live", async () => { + const prompt = vi.fn(async () => ({ stopReason: "end_turn" })); + const liveSession = makeFakeSession(); + const adapter = new OmpRuntimeAdapter({ + createAcpAdapter: () => ({ + createSession: async (): Promise => ({ + session: liveSession, + }), + promptWithFallback: prompt, + describeModel: () => "omp/default", + }), + }); + + const { session } = await adapter.createSession({ + cwd: process.cwd(), + systemPrompt: "sys", + onText: (t) => { + liveSession.callbacks.onText?.(t); + }, + }); + + // Simulate streamed text during the turn + liveSession.callbacks.onText = (t: string) => { + // turn accum is wired on create; call session path via adapter + void t; + }; + + await adapter.promptWithFallback(session, "hello"); + expect(prompt).toHaveBeenCalledWith(session, "hello", undefined); + }); + + it("re-surfaces diagnostics when the session has no live connection", async () => { + const onText = vi.fn(); + const dead = makeFakeSession({ connection: undefined, callbacks: { onText } }); + const adapter = new OmpRuntimeAdapter({ + createAcpAdapter: () => ({ + createSession: async () => ({ session: dead }), + promptWithFallback: async () => undefined, + describeModel: () => "omp/default", + }), + }); + + // Manually set adapters map by creating then killing connection semantics: + // createSession without connection still stores adapter, but hasConnection checks connection field. + const createOnly = new OmpRuntimeAdapter({ + createAcpAdapter: () => ({ + createSession: async () => ({ + session: makeFakeSession({ + connection: undefined, + callbacks: { onText }, + state: { messages: [], errorMessage: "boom" }, + }), + }), + promptWithFallback: async () => undefined, + describeModel: () => "omp/default", + }), + }); + + const { session } = await createOnly.createSession({ + cwd: process.cwd(), + systemPrompt: "", + onText, + }); + // Force no connection for follow-up + (session as { connection?: unknown }).connection = undefined; + onText.mockClear(); + await createOnly.promptWithFallback(session, "again"); + expect(onText).toHaveBeenCalled(); + expect(String(onText.mock.calls[0]?.[0])).toMatch(/no live connection/); + }); + + it("describeModel uses lastModelDescription", async () => { + const adapter = new OmpRuntimeAdapter({ + createAcpAdapter: () => ({ + createSession: async () => ({ + session: makeFakeSession({ lastModelDescription: "omp/claude-sonnet-4", model: "claude-sonnet-4" }), + }), + promptWithFallback: async () => undefined, + describeModel: () => "omp/claude-sonnet-4", + }), + }); + const { session } = await adapter.createSession({ cwd: process.cwd(), systemPrompt: "" }); + expect(adapter.describeModel(session)).toMatch(/^omp\//); + }); +}); diff --git a/plugins/fusion-plugin-omp-runtime/src/__tests__/tool-bridge.test.ts b/plugins/fusion-plugin-omp-runtime/src/__tests__/tool-bridge.test.ts new file mode 100644 index 0000000000..0a47de9120 --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/src/__tests__/tool-bridge.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { + FUSION_OMP_TOOL_BRIDGE_URL, + startFusionToolBridge, + toolsToMcpToolDefs, +} from "../tool-bridge.js"; +import { buildOmpFusionToolRules } from "../runtime-adapter.js"; + +describe("tool-bridge", () => { + it("filters built-ins and maps tool schemas", () => { + expect( + toolsToMcpToolDefs([ + { name: "read", description: "builtin", parameters: {} }, + { + name: "fn_task_list", + description: "List tasks", + parameters: { type: "object", properties: {} }, + }, + ]), + ).toEqual([ + { + name: "fn_task_list", + description: "List tasks", + inputSchema: { type: "object", properties: {} }, + }, + ]); + }); + + it("starts a bridge that executes Fusion custom tools over HTTP", async () => { + const bridge = await startFusionToolBridge([ + { + name: "fn_task_list", + description: "List tasks", + parameters: { type: "object", properties: {} }, + execute: async () => ({ text: "FN-1 todo" }), + }, + { + name: "fn_task_show", + description: "Show task", + parameters: { + type: "object", + properties: { id: { type: "string" } }, + }, + execute: async (_id, params) => { + const id = (params as { id?: string })?.id ?? "?"; + return { text: `task ${id}` }; + }, + }, + ]); + expect(bridge).not.toBeNull(); + expect(bridge!.toolCount).toBe(2); + expect(bridge!.mcpServer.name).toBe("fusion-custom-tools"); + expect(bridge!.mcpServer).toMatchObject({ + command: process.execPath, + env: [expect.objectContaining({ name: FUSION_OMP_TOOL_BRIDGE_URL })], + }); + + const env = "env" in bridge!.mcpServer ? bridge!.mcpServer.env : []; + const bridgeUrl = env.find((e) => e.name === FUSION_OMP_TOOL_BRIDGE_URL)?.value; + expect(bridgeUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + + const listRes = await fetch(`${bridgeUrl}/tool-call`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "fn_task_list", arguments: {} }), + }); + const listBody = (await listRes.json()) as { + isError?: boolean; + content?: Array<{ text?: string }>; + }; + expect(listBody.isError).toBe(false); + expect(listBody.content?.[0]?.text).toContain("FN-1"); + + const showRes = await fetch(`${bridgeUrl}/tool-call`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "fn_task_show", arguments: { id: "FN-2" } }), + }); + const showBody = (await showRes.json()) as { + isError?: boolean; + content?: Array<{ text?: string }>; + }; + expect(showBody.isError).toBe(false); + expect(showBody.content?.[0]?.text).toContain("FN-2"); + + await bridge!.dispose(); + }); + + it("returns null when there are no custom tools", async () => { + expect(await startFusionToolBridge([])).toBeNull(); + expect(await startFusionToolBridge(undefined)).toBeNull(); + }); + + it("describes fusion tools in system rules", () => { + const rules = buildOmpFusionToolRules({ fusionToolCount: 12, operatorMcpCount: 1 }); + expect(rules).toContain("fusion-custom-tools"); + expect(rules).toContain("12"); + expect(rules).toContain("fn_"); + expect(rules).toContain("Operator MCP"); + }); +}); diff --git a/plugins/fusion-plugin-omp-runtime/src/acp-settings.ts b/plugins/fusion-plugin-omp-runtime/src/acp-settings.ts new file mode 100644 index 0000000000..ed0ecfb038 --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/src/acp-settings.ts @@ -0,0 +1,131 @@ +/* +FNXC:OmpAcp 2026-07-11-23:35: +Route Oh My Pi through native ACP (`omp acp` / `omp --mode acp`) for realtime +session/update streaming, tool visibility, multi-turn reuse, and Fusion +permission-gate integration. Docs: https://omp.sh/docs/acp + +Env is allow-listed (never full process.env) but must include HOME/PATH/XDG so +omp can read provider keys and OAuth state under ~/.omp (agent auth method). +*/ + +/** Env vars forwarded to the `omp acp` subprocess. */ +export const OMP_ACP_ENV_ALLOWLIST = [ + "HOME", + "PATH", + "USER", + "LOGNAME", + "SHELL", + "LANG", + "LC_ALL", + "LC_CTYPE", + "TERM", + "TERMINFO", + "TMPDIR", + "COLORTERM", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "XDG_DATA_HOME", + "XDG_STATE_HOME", + // Common provider keys operators may set for omp (agent auth reuses ~/.omp too). + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "OPENROUTER_API_KEY", + "GOOGLE_API_KEY", + "GEMINI_API_KEY", + "XAI_API_KEY", + "GROK_API_KEY", +] as const; + +/** + * FNXC:OmpAcp 2026-07-11-23:35: + * omp ACP docs: when the client does not opt into terminal auth, the only method + * is `agent` — reusing provider keys and OAuth already configured under ~/.omp. + * Prefer `agent` first; keep `terminal` as a non-preferred fallback if advertised + * (headless Fusion cannot drive the TUI login flow). + */ +export function resolveOmpAcpAuthPreferMethods(): string[] { + return ["agent", "terminal"]; +} + +/** + * Build argv for native OMP ACP mode. + * + * FNXC:OmpAcp 2026-07-11-23:35: + * Canonical launch from https://omp.sh/docs/acp: + * `omp acp` (equivalent to `omp --mode acp`) + * Optional model flag is placed before the mode: `omp --model acp`. + * Subprocess uses JSON-RPC framing on stdio — never pass interactive TUI flags. + */ +export function buildOmpAcpArgs(options?: { model?: string }): string[] { + const args: string[] = []; + const cliModel = options?.model?.trim(); + if (cliModel) { + args.push("--model", cliModel); + } + args.push("acp"); + return args; +} + +/** + * Normalize provider-qualified model ids to the bare id the CLI accepts. + * `omp/default` and empty → omit `--model` (CLI default model). + */ +export function normalizeOmpCliModel(model: string | undefined): string | undefined { + const normalized = model?.trim(); + if (!normalized) return undefined; + for (const prefix of ["omp-cli/", "omp/"]) { + if (normalized.startsWith(prefix)) { + const stripped = normalized.slice(prefix.length).trim(); + return stripped.length > 0 ? stripped : undefined; + } + } + return normalized; +} + +/** Concrete model id for `--model`, or undefined to let omp pick its default. */ +export function modelForCli(model: string | undefined): string | undefined { + const normalized = normalizeOmpCliModel(model); + return normalized && normalized !== "default" ? normalized : undefined; +} + +/** Settings bag accepted by AcpRuntimeAdapter for an OMP ACP session. */ +export function buildOmpAcpRuntimeSettings(options: { + binary: string; + model?: string; + /** Advertise client fs/read (default false — omp has native tools). */ + fsRead?: boolean; + /** Advertise client fs/write (default false). */ + fsWrite?: boolean; +}): Record { + const cliModel = modelForCli(options.model); + return { + acpBinaryPath: options.binary, + acpArgs: buildOmpAcpArgs({ model: cliModel }), + acpModel: options.model ?? "omp/default", + acpEnvAllowList: [...OMP_ACP_ENV_ALLOWLIST], + // Conservative: keep protocol-level client fs off; omp tools + optional MCP + // cover filesystem work. Operators can enable via plugin settings later. + acpFsRead: options.fsRead === true, + acpFsWrite: options.fsWrite === true, + /* + FNXC:OmpAcp 2026-07-11-23:35: + OMP is an operator-selected first-party CLI (not an arbitrary untrusted ACP + binary). Default Fusion policy is unrestricted; acknowledge that so + sensitive tool kinds under allow-all do not escalate every call to HITL and + hang autonomous executor turns. Non-allow policy categories still route + through the ACP permission floor (require-approval / block). + */ + acpAllowUnrestricted: true, + /* + FNXC:OmpAcp 2026-07-11-23:35: + Docs: client drives initialize → authenticate → model selection. + Prefer agent auth (reuses ~/.omp). require:false so agents already signed + in without advertising methods still proceed; failures surface on session/new. + */ + acpAuthenticate: { + preferMethods: resolveOmpAcpAuthPreferMethods(), + meta: { headless: true }, + require: false, + }, + }; +} diff --git a/plugins/fusion-plugin-omp-runtime/src/acp/VENDORED.md b/plugins/fusion-plugin-omp-runtime/src/acp/VENDORED.md new file mode 100644 index 0000000000..dea858b737 --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/src/acp/VENDORED.md @@ -0,0 +1,22 @@ +# Vendored ACP client + +**Source:** `plugins/fusion-plugin-acp-runtime/src/` (via `fusion-plugin-grok-runtime/src/acp/`) +**Vendored:** 2026-07-11 for OMP ACP self-containment + +## Why + +`fusion-plugin-omp-runtime` drives Oh My Pi (`omp`) over ACP (`omp acp`). +`fusion-plugin-acp-runtime` is **experimental / on-demand**. Importing it at runtime +would couple OMP availability to the generic ACP plugin install path. + +## What is copied + +Client-side ACP only (JSON-RPC/stdio connect, session, event bridge, permission +floor, process registry, optional client fs). OMP-specific spawn/auth lives +outside this folder (`../acp-settings.ts`, `../runtime-adapter.ts`). + +## Syncing + +When fixing ACP client bugs in `fusion-plugin-acp-runtime` or the Grok vendor +copy, re-copy the client modules into this directory and note the date in FNXC +comments. diff --git a/plugins/fusion-plugin-omp-runtime/src/acp/cli-spawn.ts b/plugins/fusion-plugin-omp-runtime/src/acp/cli-spawn.ts new file mode 100644 index 0000000000..b8423f2e83 --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/src/acp/cli-spawn.ts @@ -0,0 +1,203 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:GrokAcp 2026-07-11-16:00). */ +// Resolves the ACP agent launch configuration from plugin settings. +// +// Unlike the Claude/Droid CLIs (one fixed binary per plugin), ACP is a protocol: +// the user points this runtime at *any* ACP-compatible agent binary plus the +// flag that puts it in ACP mode (e.g. `gemini --acp`). Settings therefore carry +// an arbitrary binary + args, plus the conservative-by-default fs capability +// toggles (KTD6: writes default OFF) and an env allow-list (KTD6b). + +import { existsSync } from "node:fs"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const CLAUDE_CODE_CLI_ACP_BINARY = "claude-code-cli-acp"; + +export interface AcpBinaryResolution { + kind: "resolved" | "not_resolved"; + requested: string; + path?: string; + reason?: string; +} + +export interface AcpCliSettings { + /** Agent binary to spawn (e.g. "gemini", "npx", an absolute path). */ + binaryPath: string; + /** Arguments that launch the agent in ACP/stdio mode (e.g. ["--acp"]). */ + args: string[]; + /** Optional model identifier reported via describeModel. */ + model?: string; + /** Advertise `fs/read_text_file` capability. Default: false (opt-in). */ + fsRead: boolean; + /** Advertise `fs/write_text_file` capability. Default: false (opt-in, KTD6). */ + fsWrite: boolean; + /** + * Environment variables to forward to the agent subprocess (KTD6b allow-list). + * The agent is untrusted; inherited `process.env` is NOT forwarded. Empty by + * default — callers opt specific vars in by name. + */ + envAllowList: string[]; + /** Env allow-list entries that must be present before spawning this profile. */ + requiredEnv: string[]; + /** + * Risk S1 acknowledgement. The shipped default permission policy is + * `unrestricted` (every category → allow). Because the ACP agent is an + * untrusted subprocess, the permission floor refuses to auto-approve a + * *sensitive* category on a blanket `allow` disposition unless the user has + * explicitly acknowledged that risk by setting this true — otherwise such + * calls are escalated to approval (or denied when no approver exists). + * Default: false (safe). + */ + allowUnrestricted: boolean; + /** Bundled bridge resolution status when `acpBinaryPath` asks for it. */ + binaryResolution?: AcpBinaryResolution; + /** + * FNXC:GrokAcp 2026-07-11-15:00: + * When set, call ACP authenticate after initialize (Grok headless scripting + * contract). preferMethods are tried in order against advertised authMethods. + */ + authenticate?: { + preferMethods?: string[]; + methodId?: string; + meta?: Record; + require?: boolean; + }; +} + +function asTrimmedString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function asStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + const out = value.filter((v): v is string => typeof v === "string"); + return out.length === value.length ? out : undefined; +} + +function asBool(value: unknown): boolean { + return value === true; +} + +function pluginRootDir(): string { + return resolve(dirname(fileURLToPath(import.meta.url)), ".."); +} + +export interface ResolveBundledClaudeBridgeOptions { + pluginRoot?: string; + exists?: (path: string) => boolean; +} + +export function bundledClaudeBridgeBinPath(pluginRoot = pluginRootDir()): string { + const extension = process.platform === "win32" ? ".cmd" : ""; + return join(pluginRoot, "node_modules", ".bin", `${CLAUDE_CODE_CLI_ACP_BINARY}${extension}`); +} + +export function resolveBundledClaudeBridgeBinary( + options: ResolveBundledClaudeBridgeOptions = {}, +): AcpBinaryResolution { + const root = options.pluginRoot ?? pluginRootDir(); + const exists = options.exists ?? existsSync; + const candidate = bundledClaudeBridgeBinPath(root); + /* + FNXC:ACP-RouteB 2026-06-14-19:47: + The Claude ACP bridge is a pinned plugin dependency, not a PATH-selected executable. Resolve the sentinel to the plugin-owned node_modules/.bin shim so a same-named global binary cannot replace the reviewed bridge. + */ + if (!exists(candidate)) { + return { + kind: "not_resolved", + requested: CLAUDE_CODE_CLI_ACP_BINARY, + path: candidate, + reason: `Bundled ${CLAUDE_CODE_CLI_ACP_BINARY} binary was not found at ${candidate}`, + }; + } + if (!isAbsolute(candidate)) { + return { + kind: "not_resolved", + requested: CLAUDE_CODE_CLI_ACP_BINARY, + path: candidate, + reason: `Bundled ${CLAUDE_CODE_CLI_ACP_BINARY} path is not absolute`, + }; + } + return { kind: "resolved", requested: CLAUDE_CODE_CLI_ACP_BINARY, path: candidate }; +} + +export function resolveCliSettings(settings?: Record): AcpCliSettings { + const requestedBinaryPath = asTrimmedString(settings?.acpBinaryPath); + let binaryPath = requestedBinaryPath ?? "acp-agent"; + let binaryResolution: AcpBinaryResolution | undefined; + if (requestedBinaryPath === CLAUDE_CODE_CLI_ACP_BINARY) { + binaryResolution = resolveBundledClaudeBridgeBinary(); + if (binaryResolution.kind === "resolved" && binaryResolution.path) { + binaryPath = binaryResolution.path; + } else { + /* + FNXC:OmpAcp 2026-07-13-23:10: + Fail closed when the Claude bridge sentinel cannot resolve to the plugin-owned shim — + do not spawn a bare PATH name that surfaces as opaque ENOENT. + */ + binaryPath = ""; + } + } + const args = asStringArray(settings?.acpArgs) ?? []; + const model = asTrimmedString(settings?.acpModel); + const fsRead = asBool(settings?.acpFsRead); + const fsWrite = asBool(settings?.acpFsWrite); + const envAllowList = asStringArray(settings?.acpEnvAllowList) ?? []; + const allowUnrestricted = asBool(settings?.acpAllowUnrestricted); + const authenticate = asAuthenticateSettings(settings?.acpAuthenticate); + if ( + binaryResolution?.kind === "not_resolved" && + requestedBinaryPath === CLAUDE_CODE_CLI_ACP_BINARY + ) { + throw new Error( + binaryResolution.reason ?? `Bundled ${CLAUDE_CODE_CLI_ACP_BINARY} binary was not resolved`, + ); + } + return { + binaryPath, + args, + model, + fsRead, + fsWrite, + envAllowList, + requiredEnv: [], + allowUnrestricted, + binaryResolution, + authenticate, + }; +} + +function asAuthenticateSettings(value: unknown): AcpCliSettings["authenticate"] { + if (value === true) { + return { preferMethods: ["xai.api_key", "cached_token"], meta: { headless: true }, require: true }; + } + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const obj = value as Record; + const preferMethods = asStringArray(obj.preferMethods); + const methodId = asTrimmedString(obj.methodId); + const meta = + obj.meta && typeof obj.meta === "object" && !Array.isArray(obj.meta) + ? (obj.meta as Record) + : { headless: true }; + const require = obj.require === true; + if (!preferMethods && !methodId && !require) return undefined; + return { + ...(preferMethods ? { preferMethods } : {}), + ...(methodId ? { methodId } : {}), + meta, + require, + }; +} + +export function resolveClaudeBridgeAskSettings(settings?: Record): AcpCliSettings { + const resolved = resolveCliSettings({ + ...settings, + acpBinaryPath: CLAUDE_CODE_CLI_ACP_BINARY, + acpArgs: [], + acpFsRead: false, + acpFsWrite: false, + acpEnvAllowList: ["HOME", "PATH"], + acpAllowUnrestricted: false, + }); + return { ...resolved, requiredEnv: ["HOME"] }; +} diff --git a/plugins/fusion-plugin-omp-runtime/src/acp/control-handler.ts b/plugins/fusion-plugin-omp-runtime/src/acp/control-handler.ts new file mode 100644 index 0000000000..37c1d0ab0b --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/src/acp/control-handler.ts @@ -0,0 +1,302 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:GrokAcp 2026-07-11-16:00). */ +// U5 — the SECURITY FLOOR for `session/request_permission`. +// +// The ACP agent is an UNTRUSTED subprocess. When it asks permission to run a +// tool call, this resolver classifies the call PER-CATEGORY against Fusion's +// live action gate and answers `allow_once` / `reject_once` / `cancelled`. +// +// Why per-category and not per-preset (S1 / KTD3a): Fusion's shipped default +// policy preset is `unrestricted` (every category → allow). Mapping a preset id +// straight to an outcome would auto-approve EVERY tool call of an untrusted +// agent the instant a user selects the ACP runtime. So we classify the call's +// `kind` into a Fusion category and read `gate.permissionPolicy.rules[category]`. +// +// Default-deny is the floor everywhere a decision can't be made safely: +// - no gate / no permissionPolicy → deny +// - an unmappable / missing / `other` kind → deny (most-restrictive) +// - `require-approval` with no HITL machinery → deny +// - the `allow_once` option isn't offered → reject (never `*_always`, S2) + +import type { + PermissionOption, + RequestPermissionResponse, + ToolCallUpdate, + ToolKind, +} from "@agentclientprotocol/sdk"; +import type { + ApprovalStatus, + FusionCategory, + GateDisposition, + PermissionGate, +} from "./types.js"; + +/** Sentinel returned by `classifyToolKind` for an unmappable kind → force deny. */ +export const DENY = "deny" as const; + +/** + * Map an ACP `toolCall.kind` to a Fusion action-gate category (KTD3a). + * + * Read-only / benign kinds map to the implicit `exempt` category (always allow). + * `other`, `undefined`, and any unknown kind map to the `DENY` sentinel — the + * most-restrictive outcome — and MUST NOT fall through to allow. + */ +export function classifyToolKind(kind: ToolKind | null | undefined): FusionCategory | "exempt" | typeof DENY { + switch (kind) { + case "execute": + return "command_execution"; + case "edit": + case "delete": + case "move": + return "file_write_delete"; + case "fetch": + return "network_api"; + case "read": + case "search": + case "think": + case "switch_mode": + return "exempt"; + // "other", undefined, null, or anything unknown → most-restrictive deny. + default: + return DENY; + } +} + +/** + * Select the ACP option to answer with, honoring the allow_once-ONLY rule (S2). + * + * - `allow` → an option whose `kind === "allow_once"`. Never `allow_always` + * (delegating a blanket grant to untrusted code loses Fusion's per-call + * interception). If no `allow_once` option is offered → fall back to deny. + * - `deny` → an option whose `kind === "reject_once"`. If none is offered the + * caller answers `{ outcome: "cancelled" }`. Never `reject_always`. + */ +export function selectOption( + decision: "allow" | "deny", + options: PermissionOption[], +): { decision: "allow" | "deny"; optionId?: string } { + const list = Array.isArray(options) ? options : []; + if (decision === "allow") { + const allowOnce = list.find((o) => o?.kind === "allow_once"); + if (allowOnce?.optionId) return { decision: "allow", optionId: allowOnce.optionId }; + // No allow_once offered: do NOT up-grade to allow_always. Fall back to deny. + const rejectOnce = list.find((o) => o?.kind === "reject_once"); + return { decision: "deny", optionId: rejectOnce?.optionId }; + } + const rejectOnce = list.find((o) => o?.kind === "reject_once"); + return { decision: "deny", optionId: rejectOnce?.optionId }; +} + +/** Build the ACP response for a resolved {decision, optionId}. */ +function buildResponse(sel: { + decision: "allow" | "deny"; + optionId?: string; +}): RequestPermissionResponse { + if (sel.optionId) { + return { outcome: { outcome: "selected", optionId: sel.optionId } }; + } + // No usable option (e.g. deny with no reject_once offered) → cancelled. + return { outcome: { outcome: "cancelled" } }; +} + +/** + * Read the raw per-category disposition from the live policy (exempt → allow), + * before the Risk S1 acknowledgement escalation. Callers that gate untrusted + * actions should use `effectiveDisposition` (which applies the escalation); this + * is the unescalated primitive it builds on. + */ +export function dispositionFor( + category: FusionCategory | "exempt", + gate: PermissionGate, +): GateDisposition { + if (category === "exempt") return "allow"; + const rules = gate.permissionPolicy?.rules; + const disposition = rules?.[category]; + // A category with no explicit rule is treated as require-approval (not allow): + // never silently allow an unmapped category for an untrusted agent. + return disposition ?? "require-approval"; +} + +/** A stable dedupe key for an identical tool call (decision reuse). */ +function dedupeKeyFor(toolCall: ToolCallUpdate, category: string): string { + return [toolCall.toolCallId ?? "", category, toolCall.title ?? ""].join("|"); +} + +/** + * Run the human-in-the-loop approval flow for a `require-approval` category. + * + * Requires `createApprovalRequest` (the one non-optional HITL closure). When it + * is absent there is no human channel → DEFAULT-DENY (never throw, never allow). + * + * Flow: reuse a prior decision via `findApprovalByDedupeKey` when present; + * otherwise register the request, block on `pauseForApproval`, re-read the final + * status, finalize via `markApprovalCompleted`. `approved` → allow; everything + * else (denied / pending / completed / lookup-failure) → deny. + */ +async function runApproval( + toolCall: ToolCallUpdate, + category: FusionCategory, + gate: PermissionGate, +): Promise<"allow" | "deny"> { + return runApprovalForCategory(gate, { + category, + toolName: toolCall.title ?? category, + dedupeKey: dedupeKeyFor(toolCall, category), + args: + toolCall.rawInput && typeof toolCall.rawInput === "object" + ? (toolCall.rawInput as Record) + : {}, + }); +} + +/** + * Run the HITL approval flow for an arbitrary `require-approval` action, + * identified by a category + dedupe key (not necessarily an ACP `toolCall`). + * + * Exported so the fs `writeTextFile` path (U7) routes its `file_write_delete` + * gating through the IDENTICAL approval machinery as U5 — register, block on + * `pauseForApproval`, re-read the final status, finalize — with the same + * default-deny floor when no human channel exists. Never throws, never allows + * on failure. + */ +export async function runApprovalForCategory( + gate: PermissionGate, + req: { + category: FusionCategory; + toolName: string; + dedupeKey: string; + args?: Record; + }, +): Promise<"allow" | "deny"> { + const { category, dedupeKey } = req; + if (typeof gate.createApprovalRequest !== "function") { + // No human channel available → default-deny. + return "deny"; + } + + const decisionPayload = { + disposition: "require-approval" as const, + category, + toolName: req.toolName, + approvalDedupeKey: dedupeKey, + }; + + const mapStatus = (status: ApprovalStatus | undefined): "allow" | "deny" => + status === "approved" ? "allow" : "deny"; + + try { + // Reuse a prior decision for an identical call when available. + if (typeof gate.findApprovalByDedupeKey === "function") { + const prior = await gate.findApprovalByDedupeKey(dedupeKey); + if (prior && (prior.status === "approved" || prior.status === "denied")) { + return mapStatus(prior.status); + } + } + + // Default-deny BEFORE creating a request when the HITL round-trip cannot + // complete: without `pauseForApproval` we cannot block for a decision, and + // without `findApprovalByDedupeKey` we cannot READ the decision after the + // pause — a human approval would be silently discarded (mapStatus(undefined) + // → deny). Denying upfront never orphans a pending record and never wastes + // a human's approval on an outcome that would be denied anyway. + if ( + typeof gate.pauseForApproval !== "function" || + typeof gate.findApprovalByDedupeKey !== "function" + ) { + return "deny"; + } + + const created = (await gate.createApprovalRequest( + decisionPayload, + req.args ?? {}, + )) as { id?: string } | undefined; + const approvalRequestId = typeof created?.id === "string" ? created.id : dedupeKey; + + await gate.pauseForApproval({ approvalRequestId, decision: decisionPayload }); + + // Re-read the final status after the pause resolves. + let finalStatus: ApprovalStatus | undefined; + if (typeof gate.findApprovalByDedupeKey === "function") { + const resolved = await gate.findApprovalByDedupeKey(dedupeKey); + finalStatus = resolved?.status; + } + + if (typeof gate.markApprovalCompleted === "function") { + await gate.markApprovalCompleted(approvalRequestId); + } + + return mapStatus(finalStatus); + } catch { + // Any HITL failure (timeout/dismiss/store error) → default-deny, no throw. + return "deny"; + } +} + +/** + * The full per-call security floor: classify → read the per-category + * disposition → run HITL for `require-approval` → select an `allow_once`-only + * option → build the ACP response. + * + * Default-deny on: missing gate, missing `permissionPolicy`, unmappable kind, + * `require-approval` without a resolvable approver, or a missing `allow_once` + * option. + */ +export interface ResolvePermissionOptions { + /** + * Risk S1 acknowledgement. When false (the safe default), a blanket `allow` + * disposition on a *sensitive* category is escalated to `require-approval` + * rather than auto-approved — so the shipped `unrestricted` default policy + * does not silently green-light an untrusted agent's command/file/network + * calls. The user opts out of the escalation by acknowledging the risk. + */ + allowUnrestricted?: boolean; +} + +/** + * Per-category disposition with the Risk S1 acknowledgement escalation applied: + * a *sensitive* category the policy would `allow` is upgraded to + * `require-approval` unless `allowUnrestricted` is set. `exempt` (read-only) + * never escalates. Exported so the fs write path applies the identical rule. + */ +export function effectiveDisposition( + category: FusionCategory | "exempt", + gate: PermissionGate, + opts?: ResolvePermissionOptions, +): GateDisposition { + const disposition = dispositionFor(category, gate); + if (disposition === "allow" && category !== "exempt" && opts?.allowUnrestricted !== true) { + return "require-approval"; + } + return disposition; +} + +export async function resolvePermission( + toolCall: ToolCallUpdate, + options: PermissionOption[], + gate: PermissionGate | undefined, + opts?: ResolvePermissionOptions, +): Promise { + // No gate / no policy → default-deny. + if (!gate || !gate.permissionPolicy) { + return buildResponse(selectOption("deny", options)); + } + + const category = classifyToolKind(toolCall?.kind); + // Unmappable / missing / `other` kind → most-restrictive deny. + if (category === DENY) { + return buildResponse(selectOption("deny", options)); + } + + // Per-category disposition + S1 acknowledgement escalation. + const disposition = effectiveDisposition(category, gate, opts); + + if (disposition === "allow") { + return buildResponse(selectOption("allow", options)); + } + if (disposition === "block") { + return buildResponse(selectOption("deny", options)); + } + + // require-approval → HITL (or default-deny when no human channel exists). + const decision = await runApproval(toolCall, category as FusionCategory, gate); + return buildResponse(selectOption(decision, options)); +} diff --git a/plugins/fusion-plugin-omp-runtime/src/acp/event-bridge.ts b/plugins/fusion-plugin-omp-runtime/src/acp/event-bridge.ts new file mode 100644 index 0000000000..413490ecac --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/src/acp/event-bridge.ts @@ -0,0 +1,313 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:GrokAcp 2026-07-11-16:00). */ +// Event bridge: translate ACP `session/update` notifications into Fusion's +// `AgentRuntime` callbacks (onText / onThinking / onToolStart / onToolEnd) so an +// ACP agent renders identically to existing runtimes. +// +// Scope (U4): mapping only. Output BYTE bounds + string sanitization are U6 — no +// caps are applied here. Permission requests are U5. +// +// Design notes: +// - Tolerant: every field except the `sessionUpdate` discriminator and +// `toolCallId` is optional/partial. The handler NEVER throws on a malformed or +// partial update; unknown/forward-compat tags are ignored silently. +// - Tool start/end correlation: a `tool_call` records `{ title, kind }` keyed by +// `toolCallId`; a later `tool_call_update` carries that metadata forward when +// the update omits it, then fires `onToolEnd` once the status reaches a +// terminal value (`completed` / `failed`). +// - Plans are FULL REPLACEMENTS: each `plan` (or `plan_update`) update replaces +// the prior snapshot wholesale; we never accumulate across updates. + +import type { + SessionUpdate, + ContentBlock, + ToolKind, + PlanEntry, +} from "@agentclientprotocol/sdk"; +import type { AcpCallbacks } from "./types.js"; +import { toolDisplayName, normalizeToolArgs } from "./tool-mapping.js"; +import { stripControlSequences, boundString, boundIdentifier } from "./sanitize.js"; + +// --- U6 untrusted-input bounds (Risk S5) ----------------------------------- +// +// The agent is untrusted input. The high inactivity ceiling (KTD4) does NOT +// bound an *actively* flooding agent, so the bridge caps what it forwards. + +/** + * Per-turn cumulative cap (chars) on forwarded text+thinking. Once exceeded, the + * bridge stops forwarding further text/thinking and emits ONE truncation flag. + * Cleared by `reset()` at the start of each prompt turn. ~5M chars ≈ 5 MB. + */ +export const PER_TURN_OUTPUT_CAP_CHARS = 5_000_000; + +/** Per-chunk cap (chars) applied to a single content chunk before forwarding. */ +export const PER_CHUNK_CAP_CHARS = 64_000; + +/** + * Max number of distinct `toolCallId`s tracked in the correlation map. A flooding + * agent supplying unbounded unique ids must not grow the map without limit — + * oldest entries are evicted once the cap is exceeded (bounded memory). + */ +export const TOOL_CALL_MAP_CAP = 1000; + +/** + * Max plan entries formatted into the plan log line. Entry size is bounded in + * formatPlan; this bounds the COUNT so one plan event cannot bypass the + * per-turn output budget with thousands of 64KB entries (Risk S5). + */ +export const MAX_PLAN_ENTRIES = 100; + +/** Tracked metadata for an in-flight tool call, keyed by `toolCallId`. */ +interface TrackedToolCall { + title?: string | null; + kind?: ToolKind | null; + /** Whether onToolEnd has already fired (terminal status seen). */ + ended: boolean; +} + +export interface EventBridge { + /** Process one `session/update` payload (`params.update`). Never throws. */ + handleSessionUpdate(update: SessionUpdate): void; + /** Clear per-turn correlation state (tool calls, plan snapshot, last text). */ + reset(): void; +} + +/** Extract plain text from a `ContentBlock`, or `undefined` for non-text blocks. */ +function extractText(content: ContentBlock | undefined): string | undefined { + if (content && content.type === "text" && typeof content.text === "string") { + return content.text; + } + return undefined; +} + +/** + * Repair the specific "sentence punctuation + capitalized next sentence" case + * where an agent splits adjacent sentences across chunks without the separating + * space. Mirrors the droid runtime's `normalizeStreamingDelta` — conservative so + * code, domains, and lowercase continuations are left untouched. + */ +function normalizeStreamingDelta(previousText: string, nextDelta: string): string { + if (!previousText || !nextDelta) return nextDelta; + const previousChar = previousText.slice(-1); + const nextChar = nextDelta[0] ?? ""; + if (/\s/.test(previousChar) || /\s/.test(nextChar)) return nextDelta; + if (/[.!?]/.test(previousChar) && /[A-Z0-9"'([]/.test(nextChar)) { + return ` ${nextDelta}`; + } + return nextDelta; +} + +/** Format a plan snapshot into a single thinking/log line. */ +function formatPlan(entries: PlanEntry[]): string { + const lines = entries.map((entry) => { + const status = typeof entry.status === "string" ? entry.status : "pending"; + // Plan text is agent-supplied — sanitize control/ANSI before it reaches a + // log/UI line (Risk S7) and bound its length (Risk S5). + const rawText = typeof entry.content === "string" ? entry.content : ""; + const text = boundString(stripControlSequences(rawText), PER_CHUNK_CAP_CHARS); + return `- [${stripControlSequences(status)}] ${text}`; + }); + return `Plan:\n${lines.join("\n")}`; +} + +export function createEventBridge(callbacks: AcpCallbacks): EventBridge { + // Start/end correlation across `tool_call` → `tool_call_update`. Insertion + // order is preserved by Map, so the oldest key is the first iterator entry — + // used for FIFO eviction once TOOL_CALL_MAP_CAP is exceeded (Risk S5). + const toolCalls = new Map(); + // Running text/thinking accumulators for delta-space repair across chunks. + let textSoFar = ""; + let thinkingSoFar = ""; + // Cumulative chars forwarded (text+thinking) this turn (Risk S5). + let cumulativeOutputChars = 0; + // Whether the per-turn cap was hit and the single flag line already emitted. + let outputCapFlagged = false; + + function reset(): void { + toolCalls.clear(); + textSoFar = ""; + thinkingSoFar = ""; + cumulativeOutputChars = 0; + outputCapFlagged = false; + } + + /** + * Track a bounded toolCallId for use as a Map key, evicting the oldest entry + * when the cap is exceeded so a flood of unique ids cannot grow memory without + * limit. Returns the normalized id, or `undefined` when the id is empty. + */ + function setTracked(rawId: string, tracked: TrackedToolCall): string | undefined { + const id = boundIdentifier(rawId); + if (id === "") return undefined; + /* + FNXC:OmpAcp 2026-07-13-23:10: + Map.set on an existing key does not move recency — delete first so re-track is true LRU. + For a new key, evict the oldest when at cap so memory stays bounded. + */ + if (toolCalls.has(id)) { + toolCalls.delete(id); + } else if (toolCalls.size >= TOOL_CALL_MAP_CAP) { + const oldest = toolCalls.keys().next().value; + if (oldest !== undefined) toolCalls.delete(oldest); + } + toolCalls.set(id, tracked); + return id; + } + + /** + * Forward one sanitized + bounded delta through `emit`, honoring the per-turn + * cumulative cap. Once the cap is exceeded, forwarding stops and a single + * truncation flag line is emitted via `onThinking`. + */ + function forwardBounded( + raw: string, + prior: string, + emit: (delta: string) => void, + ): string { + if (outputCapFlagged) return prior; + if (cumulativeOutputChars >= PER_TURN_OUTPUT_CAP_CHARS) { + outputCapFlagged = true; + callbacks.onThinking?.( + "[output truncated: per-turn limit reached — further agent output suppressed]", + ); + return prior; + } + // Sanitize control/ANSI (Risk S7) and bound the single chunk (Risk S5). + const sanitized = boundString(stripControlSequences(raw), PER_CHUNK_CAP_CHARS); + if (sanitized === "") return prior; + const delta = normalizeStreamingDelta(prior, sanitized); + cumulativeOutputChars += delta.length; + emit(delta); + return prior + delta; + } + + function emitText(content: ContentBlock | undefined): void { + const raw = extractText(content); + if (raw === undefined || raw === "") return; + textSoFar = forwardBounded(raw, textSoFar, (delta) => callbacks.onText?.(delta)); + } + + function emitThinking(content: ContentBlock | undefined): void { + const raw = extractText(content); + if (raw === undefined || raw === "") return; + thinkingSoFar = forwardBounded(raw, thinkingSoFar, (delta) => + callbacks.onThinking?.(delta), + ); + } + + /** Sanitize an agent-supplied tool title before it reaches a callback/log (S7). */ + function safeTitle(title: string | null | undefined): string | null | undefined { + if (typeof title !== "string") return title; + return boundString(stripControlSequences(title), PER_CHUNK_CAP_CHARS); + } + + function handleToolCall(update: Extract): void { + if (typeof update.toolCallId !== "string") return; + const title = safeTitle(update.title); + const id = setTracked(update.toolCallId, { title, kind: update.kind, ended: false }); + if (id === undefined) return; + const name = toolDisplayName({ title, kind: update.kind }); + callbacks.onToolStart?.(name, normalizeToolArgs(update.rawInput)); + } + + function handleToolCallUpdate( + update: Extract, + ): void { + if (typeof update.toolCallId !== "string") return; + const id = boundIdentifier(update.toolCallId); + if (id === "") return; + const tracked = toolCalls.get(id) ?? { ended: false }; + // Carry forward title/kind from the prior `tool_call` when this update omits + // them (a partial update may only set status/output). + if (update.title != null) tracked.title = safeTitle(update.title); + if (update.kind != null) tracked.kind = update.kind; + // `id` is already bounded above; setTracked re-keys with the same value. + setTracked(id, tracked); + + const status = update.status; + if (status !== "completed" && status !== "failed") { + // Intermediate (pending/in_progress) — tracking updated, no callback. + return; + } + if (tracked.ended) return; // already fired a terminal callback + tracked.ended = true; + const name = toolDisplayName({ title: tracked.title, kind: tracked.kind }); + callbacks.onToolEnd?.(name, status === "failed", update.rawOutput); + } + + function handlePlan(entries: PlanEntry[] | undefined): void { + // FULL REPLACEMENT: drop any prior snapshot, surface the new one once. + // Plan output is charged against the same per-turn budget as text/thinking + // (Risk S5): entry SIZE is bounded in formatPlan, but entry COUNT is + // agent-controlled — without the cap below, one plan event with thousands + // of entries bypasses the per-turn ceiling entirely. + if (outputCapFlagged) return; + // Enforce the ceiling on the plan path too: without this check a plan-ONLY + // stream (no text/thinking ever entering forwardBounded) would keep + // emitting forever after crossing the budget. + if (cumulativeOutputChars >= PER_TURN_OUTPUT_CAP_CHARS) { + outputCapFlagged = true; + callbacks.onThinking?.( + "[output truncated: per-turn limit reached — further agent output suppressed]", + ); + return; + } + const list = Array.isArray(entries) ? entries : []; + const capped = list.slice(0, MAX_PLAN_ENTRIES); + let line = formatPlan(capped); + if (list.length > capped.length) { + line += `\n- … ${list.length - capped.length} more entries truncated`; + } + line = boundString(line, PER_CHUNK_CAP_CHARS); + cumulativeOutputChars += line.length; + callbacks.onThinking?.(line); + } + + function handleSessionUpdate(update: SessionUpdate): void { + if (!update || typeof update !== "object") return; + try { + switch (update.sessionUpdate) { + case "agent_message_chunk": + emitText(update.content); + break; + case "agent_thought_chunk": + emitThinking(update.content); + break; + case "user_message_chunk": + // Echo of user input — ignored in v1. + break; + case "tool_call": + handleToolCall(update); + break; + case "tool_call_update": + handleToolCallUpdate(update); + break; + case "plan": + handlePlan(update.entries); + break; + case "plan_update": + // The (experimental) `PlanUpdate` variant carries a `plan` field, NOT a + // top-level `entries` array — so there is nothing here to map to our + // entries-based snapshot. v1 treats it as a NO-OP rather than wiping the + // prior plan: the full `plan` event remains the source of truth. + break; + case "plan_removed": + // Clearing the plan: surface nothing. + break; + case "available_commands_update": + case "current_mode_update": + case "config_option_update": + case "session_info_update": + case "usage_update": + // Stored/ignored in v1 — no callback surface. + break; + default: + // Unknown/forward-compat tag — ignore without throwing. + break; + } + } catch { + // Tolerant: a malformed/partial update must never break the stream. + } + } + + return { handleSessionUpdate, reset }; +} diff --git a/plugins/fusion-plugin-omp-runtime/src/acp/fs-capabilities.ts b/plugins/fusion-plugin-omp-runtime/src/acp/fs-capabilities.ts new file mode 100644 index 0000000000..b1002fcd92 --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/src/acp/fs-capabilities.ts @@ -0,0 +1,258 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:GrokAcp 2026-07-11-16:00). */ +// U7 — client filesystem capabilities behind the path jail (KTD6 / Risk S3/S4/S5). +// +// These handlers back the ACP `fs/read_text_file` / `fs/write_text_file` client +// methods. They exist ONLY when the resolved settings opt in (KTD6): reads are +// opt-in, writes default OFF and are additionally routed through the action gate +// as a `file_write_delete` category (reusing the U5 floor — never a free +// capability). Every path crosses `assertPathWithinCwd` (the symlink-resolving +// jail) before any byte is read or written, and the secret/git deny-lists apply +// regardless of cwd membership. +// +// On ANY rejection (jail / deny-list / policy / oversize) these THROW — the SDK +// surfaces the throw as a JSON-RPC error. They MUST NEVER silently succeed. + +import { constants as fsConstants } from "node:fs"; +import type { + ReadTextFileRequest, + ReadTextFileResponse, + WriteTextFileRequest, + WriteTextFileResponse, +} from "@agentclientprotocol/sdk"; +import { + assertPathWithinCwd, + isGitInternal, + isSecretPath, + openWithinCwd, + PathJailError, +} from "./path-jail.js"; +import { effectiveDisposition, runApprovalForCategory } from "./control-handler.js"; +import type { PermissionGate } from "./types.js"; + +/** Hard ceiling on bytes returned from a read when `limit` is absent/huge (S5). */ +export const DEFAULT_READ_MAX_BYTES = 5 * 1024 * 1024; // 5 MiB + +/** Hard ceiling on bytes accepted for a single write (S5). */ +export const DEFAULT_WRITE_MAX_BYTES = 5 * 1024 * 1024; // 5 MiB + +/** Thrown when a write's content exceeds the size ceiling. */ +export class FsContentTooLargeError extends Error { + readonly code = "content_too_large" as const; + constructor(readonly limitBytes: number) { + super(`fs write content exceeds the ${limitBytes}-byte ceiling`); + this.name = "FsContentTooLargeError"; + } +} + +/** Thrown when a gated write is blocked by the permission policy. */ +export class FsWriteDeniedError extends Error { + readonly code = "write_denied" as const; + constructor(message: string) { + super(message); + this.name = "FsWriteDeniedError"; + } +} + +export interface FsHandlerOptions { + /** Confinement root — the task worktree (session cwd). */ + cwd: string; + /** Per-run permission gate (U5). Required for write gating. */ + gate?: PermissionGate; + /** Advertise/register `readTextFile`. */ + allowRead: boolean; + /** Advertise/register `writeTextFile` (default OFF — KTD6). */ + allowWrite: boolean; + /** + * Risk S1 acknowledgement. When false (default), a blanket `allow` on the + * `file_write_delete` category is escalated to `require-approval` for the + * untrusted agent rather than auto-approved. + */ + allowUnrestricted?: boolean; + /** Override the read byte ceiling (tests). */ + readMaxBytes?: number; + /** Override the write byte ceiling (tests). */ + writeMaxBytes?: number; +} + +export interface FsHandlers { + readTextFile?: (params: ReadTextFileRequest) => Promise; + writeTextFile?: (params: WriteTextFileRequest) => Promise; +} + +/** + * Apply the `line`/`limit` window AND the hard byte ceiling to file content. + * + * `line` is 1-based (per the ACP schema). `limit` caps the number of lines. When + * `limit` is absent or absurdly large the byte ceiling still bounds the result + * so a multi-GB file can't be slurped into memory (S5). + */ +export function applyReadWindow( + content: string, + line: number | null | undefined, + limit: number | null | undefined, + maxBytes: number, +): string { + let out = content; + const hasLine = typeof line === "number" && Number.isFinite(line) && line > 1; + const hasLimit = typeof limit === "number" && Number.isFinite(limit) && limit > 0; + + if (hasLine || hasLimit) { + const lines = content.split("\n"); + const start = hasLine ? Math.floor(line as number) - 1 : 0; + const end = hasLimit ? start + Math.floor(limit as number) : lines.length; + out = lines.slice(start, end).join("\n"); + } + + // Byte ceiling regardless of line/limit (truncate on a UTF-8 boundary-safe + // basis by slicing the buffer then decoding). + const buf = Buffer.from(out, "utf8"); + if (buf.byteLength > maxBytes) { + out = buf.subarray(0, maxBytes).toString("utf8"); + } + return out; +} + +/** + * Build the fs handlers, returning ONLY the ones enabled by settings. The + * provider registers these on the `Client` impl iff the matching capability is + * advertised (consistency invariant — KTD6). + */ +export function createFsHandlers(opts: FsHandlerOptions): FsHandlers { + const readMaxBytes = opts.readMaxBytes ?? DEFAULT_READ_MAX_BYTES; + const writeMaxBytes = opts.writeMaxBytes ?? DEFAULT_WRITE_MAX_BYTES; + const handlers: FsHandlers = {}; + + if (opts.allowRead) { + handlers.readTextFile = async ( + params: ReadTextFileRequest, + ): Promise => { + const resolved = await assertPathWithinCwd(params.path, opts.cwd); + // Secrets that legitimately live inside the worktree are still denied. + if (isSecretPath(resolved)) { + throw new PathJailError( + "denied_secret", + `read of secret-pattern file denied: ${resolved}`, + ); + } + // Reading git internals is also denied (config/token surface). + if (isGitInternal(resolved)) { + throw new PathJailError( + "denied_git", + `read of git-internal file denied: ${resolved}`, + ); + } + + // Atomic, symlink-safe open (TOCTOU defense), then read. + const handle = await openWithinCwd(resolved, opts.cwd, fsConstants.O_RDONLY); + try { + // DoS guard (FIX 4): a multi-GB file would OOM if we `readFile` the whole + // thing before `applyReadWindow` truncates. Always cap the read when size + // exceeds the byte ceiling — even if a line `limit` is supplied — so a + // multi-GB file with limit:1 cannot allocate the full file. applyReadWindow + // still applies line/limit/byte truncation markers on the capped content. + const stat = await handle.stat(); + let content: string; + if (stat.size > readMaxBytes) { + const buf = Buffer.alloc(readMaxBytes + 1); + const { bytesRead } = await handle.read(buf, 0, readMaxBytes + 1, 0); + content = buf.subarray(0, bytesRead).toString("utf8"); + } else { + content = await handle.readFile({ encoding: "utf8" }); + } + return { + content: applyReadWindow(content, params.line, params.limit, readMaxBytes), + }; + } finally { + await handle.close().catch(() => undefined); + } + }; + } + + if (opts.allowWrite) { + handlers.writeTextFile = async ( + params: WriteTextFileRequest, + ): Promise => { + const content = typeof params.content === "string" ? params.content : ""; + // Size ceiling BEFORE any filesystem work (S5). + if (Buffer.byteLength(content, "utf8") > writeMaxBytes) { + throw new FsContentTooLargeError(writeMaxBytes); + } + + const resolved = await assertPathWithinCwd(params.path, opts.cwd); + + // HARD-reject writes to git internals (.git/**) — RCE/token surface (S3). + if (isGitInternal(resolved)) { + throw new PathJailError( + "denied_git", + `write to git-internal path hard-rejected: ${resolved}`, + ); + } + // Never let an agent overwrite a secret either. + if (isSecretPath(resolved)) { + throw new PathJailError( + "denied_secret", + `write to secret-pattern file denied: ${resolved}`, + ); + } + + // Route the write through the action gate as `file_write_delete` (U5): + // allow → proceed, block → reject, require-approval → HITL (or + // default-deny when no human channel). Reuses the U5 helpers so the + // security floor stays single-sourced. + const gate = opts.gate; + const disposition = gate?.permissionPolicy + ? effectiveDisposition("file_write_delete", gate, { + allowUnrestricted: opts.allowUnrestricted, + }) + : "require-approval"; + + if (disposition === "block") { + throw new FsWriteDeniedError( + `file_write_delete is blocked by policy: ${resolved}`, + ); + } + if (disposition === "require-approval") { + const decision = gate + ? await runApprovalForCategory(gate, { + category: "file_write_delete", + toolName: "fs/write_text_file", + dedupeKey: `fs_write|${resolved}`, + args: { path: resolved }, + }) + : "deny"; + if (decision !== "allow") { + throw new FsWriteDeniedError( + `file_write_delete write requires approval and was not granted: ${resolved}`, + ); + } + } + // disposition === "allow" → proceed. + + // Atomic, symlink-safe create within cwd. O_NOFOLLOW (in openWithinCwd) + // guards ONLY the FINAL component; an intermediate dir swapped to a symlink + // is still followed. We therefore must NOT pass O_TRUNC into open(): doing + // so would TRUNCATE an escaped target BEFORE openWithinCwd's post-open + // realpath re-validation gets to reject it (write-path TOCTOU, FIX 3). + // Instead open create+write WITHOUT truncate, let openWithinCwd run its + // re-validation, and ONLY truncate (via the fd) AFTER it has proven the + // opened inode is still inside the jail. + const handle = await openWithinCwd( + resolved, + opts.cwd, + fsConstants.O_WRONLY | fsConstants.O_CREAT, + 0o644, + ); + try { + // Truncate-AFTER-validate: openWithinCwd returned only because the + // re-validation passed, so it is now safe to empty the file and write. + await handle.truncate(0); + await handle.writeFile(content, { encoding: "utf8" }); + } finally { + await handle.close().catch(() => undefined); + } + return {}; + }; + } + + return handlers; +} diff --git a/plugins/fusion-plugin-omp-runtime/src/acp/index.ts b/plugins/fusion-plugin-omp-runtime/src/acp/index.ts new file mode 100644 index 0000000000..e0b5fb5055 --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/src/acp/index.ts @@ -0,0 +1,21 @@ +/* +FNXC:OmpAcp 2026-07-11-23:35: +Vendored ACP client for the OMP runtime. Copied from +plugins/fusion-plugin-acp-runtime/src (via grok-runtime/src/acp) so this plugin +is self-contained and does not depend on the experimental/on-demand +fusion-plugin-acp-runtime package at runtime. Keep this tree focused on the +JSON-RPC/stdio client. OMP-specific spawn/auth live outside this folder. +*/ + +export { AcpRuntimeAdapter } from "./runtime-adapter.js"; +export { killAllProcesses } from "./process-manager.js"; +export { + authenticateAcpConnection, + AcpAuthRequiredError, + connect, + newAcpSession, + promptAcpSession, +} from "./provider.js"; +export { resolveCliSettings } from "./cli-spawn.js"; +export type { AcpCliSettings } from "./cli-spawn.js"; +export type { AcpMcpServer, AgentRuntimeOptions as AcpAgentRuntimeOptions } from "./types.js"; diff --git a/plugins/fusion-plugin-omp-runtime/src/acp/path-jail.ts b/plugins/fusion-plugin-omp-runtime/src/acp/path-jail.ts new file mode 100644 index 0000000000..9279faf4b9 --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/src/acp/path-jail.ts @@ -0,0 +1,229 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:GrokAcp 2026-07-11-16:00). */ +// U7 — the SECURITY BOUNDARY for client filesystem capabilities (KTD6a / Risk S3). +// +// `project-root-guard.ts` is a `.fusion`-suffix / git-worktree STRING check, NOT +// a path jail — it is deliberately NOT used here. This module is a real +// symlink-resolving confinement jail. The ACP agent is an untrusted subprocess; +// every path it hands to `fs/read_text_file` / `fs/write_text_file` is hostile +// input and must be proven to resolve INSIDE the session `cwd` before any open. +// +// Threats defended (each has a test): +// 1. Lexical escape — `../../etc/passwd` normalized against cwd → reject. +// 2. Symlink escape — a symlink INSIDE cwd pointing at /etc: lexical +// normalization passes but the REAL target is outside. +// We resolve realpath (follow symlinks) and require it +// within realpath(cwd). New files: validate realpath of +// the PARENT, then lstat the final component and reject +// if it is itself a symlink. +// 3. TOCTOU — `openWithinCwd` opens with O_NOFOLLOW on the final +// component and re-validates the opened fd, so a +// component cannot be swapped for a symlink between +// check and open. +// 4. Secret reads — `.env*`, `*.pem`, `*.key`, `.npmrc`, `.netrc`, +// `id_*`, `credentials` (by basename) → denied. +// 5. Git-internals write — anything under a `.git/` dir → hard-reject. +// 6. NUL bytes / absolute-escape / separator tricks → reject. + +import { constants as fsConstants } from "node:fs"; +import { open, realpath, lstat } from "node:fs/promises"; +import type { FileHandle } from "node:fs/promises"; +import * as path from "node:path"; + +/** Typed jail rejection. `code` lets callers map to the right JSON-RPC error. */ +export type PathJailErrorCode = + | "path_outside_cwd" + | "denied_secret" + | "denied_git" + | "invalid_path"; + +export class PathJailError extends Error { + readonly code: PathJailErrorCode; + constructor(code: PathJailErrorCode, message: string) { + super(message); + this.code = code; + this.name = "PathJailError"; + } +} + +/** Secret-bearing basenames/patterns that must never be read even inside cwd. */ +const SECRET_BASENAME_PATTERNS: RegExp[] = [ + /^\.env($|\..*$)/i, // .env, .env.local, .env.production, ... + /\.pem$/i, + /\.key$/i, + /^\.npmrc$/i, + /^\.netrc$/i, + /^id_.+$/i, // id_rsa, id_ed25519, id_rsa.pub, ... + /^credentials$/i, + /^\.git-credentials$/i, // git stored plaintext credentials + /\.p12$/i, // PKCS#12 keystore + /\.pfx$/i, // PKCS#12 keystore (Windows) + /\.(keystore|jks)$/i, // Java keystore + /^\.dockercfg$/i, // legacy docker registry auth + /^\.pgpass$/i, // PostgreSQL password file + /^\.htpasswd$/i, // Apache basic-auth credentials +]; + +/** + * Is `resolved` a secret file by basename? Confinement-independent: secrets that + * legitimately live inside the worktree are still denied (KTD6a deny-list). + */ +export function isSecretPath(resolved: string): boolean { + const base = path.basename(resolved); + return SECRET_BASENAME_PATTERNS.some((re) => re.test(base)); +} + +/** + * Is `resolved` inside a `.git/` directory (git internals)? Writing here yields + * RCE (`.git/hooks/pre-commit`) or token theft (`.git/config`) — hard-reject + * writes regardless of cwd membership (KTD6a deny-list). + */ +export function isGitInternal(resolved: string): boolean { + const segments = resolved.split(path.sep); + return segments.includes(".git"); +} + +/** Reject a raw request path with NUL bytes or that is empty/non-string. */ +function rejectMalformed(requestedPath: string): void { + if (typeof requestedPath !== "string" || requestedPath.length === 0) { + throw new PathJailError("invalid_path", "empty or non-string path"); + } + if (requestedPath.includes("\0")) { + throw new PathJailError("invalid_path", "path contains a NUL byte"); + } +} + +/** True iff `child` is `parent` or a descendant of it (both already real). */ +function isWithin(parent: string, child: string): boolean { + if (child === parent) return true; + const withSep = parent.endsWith(path.sep) ? parent : parent + path.sep; + return child.startsWith(withSep); +} + +/** + * Resolve `requestedPath` (relative to `cwd`, or absolute) to a SAFE absolute + * path proven to live inside the realpath of `cwd`, or throw `PathJailError`. + * + * - Existing target: resolve realpath of the target (follows all symlinks) and + * require it within realpath(cwd). + * - Non-existent target (a new file to write): resolve realpath of the PARENT + * dir, require THAT within realpath(cwd), then `lstat` the final component and + * reject if it is a symlink (a dangling symlink would otherwise let a later + * open follow it out of the jail). + * + * The returned path is `realpath(parent) + basename` — safe to hand to + * `openWithinCwd`, which re-validates atomically (O_NOFOLLOW) to close TOCTOU. + */ +export async function assertPathWithinCwd( + requestedPath: string, + cwd: string, +): Promise { + rejectMalformed(requestedPath); + + // Realpath of the confinement root. If cwd itself can't be resolved, nothing + // can be confined — treat as invalid. + let realCwd: string; + try { + realCwd = await realpath(cwd); + } catch { + throw new PathJailError("invalid_path", `cwd does not resolve: ${cwd}`); + } + + // Resolve the requested path lexically against cwd FIRST (handles `../`). + const absRequested = path.resolve(realCwd, requestedPath); + + // Try to realpath the target itself (exists case). + let resolved: string; + let targetExists = true; + try { + resolved = await realpath(absRequested); + } catch { + targetExists = false; + // Non-existent target: validate the parent dir's realpath, keep the final + // component name. The parent MUST exist and resolve inside cwd. + const parent = path.dirname(absRequested); + let realParent: string; + try { + realParent = await realpath(parent); + } catch { + throw new PathJailError( + "path_outside_cwd", + `parent directory does not resolve: ${parent}`, + ); + } + if (!isWithin(realCwd, realParent)) { + throw new PathJailError( + "path_outside_cwd", + `resolved parent escapes cwd: ${realParent}`, + ); + } + resolved = path.join(realParent, path.basename(absRequested)); + } + + if (!isWithin(realCwd, resolved)) { + throw new PathJailError( + "path_outside_cwd", + `resolved path escapes cwd: ${resolved}`, + ); + } + + // For a non-existent target, the final component must not already be a + // (dangling) symlink that a later open could follow out of the jail. + if (!targetExists) { + try { + const st = await lstat(resolved); + if (st.isSymbolicLink()) { + throw new PathJailError( + "path_outside_cwd", + `final component is a symlink: ${resolved}`, + ); + } + } catch (err) { + if (err instanceof PathJailError) throw err; + // ENOENT for a not-yet-created file is expected — fine to proceed. + } + } + + return resolved; +} + +/** + * Open a jail-validated path atomically (TOCTOU defense, Risk S3 threat 3). + * + * `safePath` MUST be the output of `assertPathWithinCwd`. We open with + * `O_NOFOLLOW` so the FINAL component is never followed if it was swapped for a + * symlink between check and open, then `fstat` + realpath-via-fd re-validate the + * actually-opened inode is still inside `realCwd`. On any mismatch we close and + * throw rather than operate on an escaped handle. + */ +export async function openWithinCwd( + safePath: string, + cwd: string, + flags: number, + mode?: number, +): Promise { + let realCwd: string; + try { + realCwd = await realpath(cwd); + } catch { + throw new PathJailError("invalid_path", `cwd does not resolve: ${cwd}`); + } + + const handle = await open(safePath, flags | fsConstants.O_NOFOLLOW, mode); + try { + // Re-validate the opened inode's real path is still within the jail. On + // Linux `/proc/self/fd/` would work; portably we realpath the safePath + // again now that O_NOFOLLOW proved the final component isn't a symlink — any + // intermediate swap would change this resolution. + const reReal = await realpath(safePath); + if (!isWithin(realCwd, reReal)) { + throw new PathJailError( + "path_outside_cwd", + `opened path escapes cwd after open: ${reReal}`, + ); + } + return handle; + } catch (err) { + await handle.close().catch(() => undefined); + throw err; + } +} diff --git a/plugins/fusion-plugin-omp-runtime/src/acp/process-manager.ts b/plugins/fusion-plugin-omp-runtime/src/acp/process-manager.ts new file mode 100644 index 0000000000..7b73a73076 --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/src/acp/process-manager.ts @@ -0,0 +1,163 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:GrokAcp 2026-07-11-16:00). */ +// port-4040-allowlist: this file documents the reserved dashboard port in kill-guard comments only; no kill targets it. +// Subprocess lifecycle for the ACP runtime. +// +// Mirrors the hardening conventions in +// `plugins/fusion-plugin-droid-runtime/src/process-manager.ts`: a self-cleaning +// process registry, SIGKILL teardown scoped to agent subprocesses only (never +// the dashboard/port-4040 — KTD4), bounded stderr capture with secret redaction +// (Risk S8), and a high inactivity ceiling (the engine's StuckTaskDetector is +// the authoritative aborter — KTD4). +// +// The ACP agent is UNTRUSTED. The spawn env is built from an explicit allow-list +// (KTD6b), never inherited `process.env`, so secret-bearing vars are not handed +// to the agent. + +import { spawn, type ChildProcess } from "node:child_process"; +import { redactSecrets } from "@fusion/core"; + +function debugLog(message: string): void { + if (process.env.PI_ACP_DEBUG !== "1" && process.env.FUSION_GROK_ACP_DEBUG !== "1") return; + console.error(`[grok-acp] ${message}`); +} + +/** Registry of active agent subprocesses for teardown. Self-cleans on exit. */ +const activeProcesses = new Set(); + +/** + * Register a subprocess in the agent process registry. + * Auto-removed from the registry when it exits. + */ +export function registerProcess(child: ChildProcess): void { + activeProcesses.add(child); + child.on("exit", () => activeProcesses.delete(child)); +} + +/** Remove a subprocess from the registry (idempotent). */ +export function unregisterProcess(child: ChildProcess): void { + activeProcesses.delete(child); +} + +/** Number of registered (presumed-live) agent subprocesses — for diagnostics/tests. */ +export function activeProcessCount(): number { + return activeProcesses.size; +} + +/** + * Force-kill a subprocess via SIGKILL. No-op if already dead (killed or exited). + * Cross-platform safe: Node treats SIGKILL as forceful termination on Windows. + */ +export function forceKill(child: ChildProcess): void { + if (child.killed || child.exitCode !== null) return; + try { + child.kill("SIGKILL"); + } catch { + // already gone + } +} + +/** + * Force-kill every registered agent subprocess and clear the registry. + * + * Scoped to agent subprocesses tracked here only — never the dashboard / port + * 4040 / any other process (KTD4 / kill-guard conventions). Safe to call + * repeatedly; no-ops on already-dead processes. + */ +export function killAllProcesses(): void { + for (const child of activeProcesses) { + forceKill(child); + } + activeProcesses.clear(); +} + +export class MissingAcpEnvError extends Error { + readonly code = "ACP_MISSING_ENV"; + constructor(readonly missingKeys: string[]) { + super(`Missing required ACP environment variable(s): ${missingKeys.join(", ")}`); + this.name = "MissingAcpEnvError"; + } +} + +export interface BuildSpawnEnvOptions { + required?: string[]; + sourceEnv?: NodeJS.ProcessEnv; +} + +/** + * Build the subprocess environment from an explicit allow-list (KTD6b). + * + * Returns ONLY allow-listed vars copied from `process.env`. The full env is + * never inherited — the agent is untrusted and must not receive secret-bearing + * vars. Returns an empty env by default (empty allow-list). + */ +export function buildSpawnEnv(allowList: string[], options: BuildSpawnEnvOptions = {}): NodeJS.ProcessEnv { + /* + FNXC:ACP-RouteB 2026-06-14-19:52: + Claude bridge subprocesses may receive HOME so the real `claude` can read ~/.claude auth and PATH so the bridge can locate sub-executables. Do not forward ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, or inherited process.env because the bridge is an untrusted external process. + */ + const sourceEnv = options.sourceEnv ?? process.env; + const env: NodeJS.ProcessEnv = {}; + for (const key of allowList) { + const value = sourceEnv[key]; + if (typeof value === "string") env[key] = value; + } + const missing = (options.required ?? []).filter((key) => typeof env[key] !== "string"); + if (missing.length > 0) { + throw new MissingAcpEnvError(missing); + } + return env; +} + +export interface SpawnAgentOptions { + binaryPath: string; + args: string[]; + cwd: string; + env: NodeJS.ProcessEnv; +} + +/** + * Spawn the ACP agent subprocess with piped stdio. + * + * Registers the child on spawn and unregisters it on exit. The caller wraps + * stdin/stdout into a web stream for `ndJsonStream`. + */ +export function spawnAgent(options: SpawnAgentOptions): ChildProcess { + const child = spawn(options.binaryPath, options.args, { + stdio: ["pipe", "pipe", "pipe"], + cwd: options.cwd, + env: options.env, + }); + registerProcess(child); + debugLog(`spawnAgent: pid=${child.pid} binary=${options.binaryPath}`); + return child; +} + +// --- stderr capture + secret redaction (Risk S8) -------------------------- + +/** Maximum stderr bytes retained; older output is dropped to bound memory. */ +const STDERR_BUFFER_CEILING = 64 * 1024; + +// Secret redaction (Risk S8) lives in @fusion/core so PTY/process owners share +// one implementation; re-exported here to preserve this module's public surface. +export { redactSecrets }; + +/** + * Accumulate stderr into a bounded, secret-redacted buffer. + * Returns a getter for the current (redacted) buffer contents. + */ +export function captureStderr(child: ChildProcess): () => string { + // FIX 5: redacting each chunk in isolation leaks a secret that straddles a + // chunk boundary (the token is split across two `data` events so neither half + // matches a pattern). Accumulate the RAW bytes into a bounded buffer first, + // then redact across the whole (bounded) buffer after each append so a + // boundary-spanning secret is caught. The buffer stays bounded by the existing + // ceiling; the returned getter always reports the redacted view. + let raw = ""; + child.stderr?.on("data", (data: Buffer) => { + raw += data.toString(); + if (raw.length > STDERR_BUFFER_CEILING) { + raw = raw.slice(raw.length - STDERR_BUFFER_CEILING); + } + }); + return () => redactSecrets(raw); +} diff --git a/plugins/fusion-plugin-omp-runtime/src/acp/prompt-builder.ts b/plugins/fusion-plugin-omp-runtime/src/acp/prompt-builder.ts new file mode 100644 index 0000000000..b8ee656b32 --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/src/acp/prompt-builder.ts @@ -0,0 +1,50 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:GrokAcp 2026-07-11-16:00). */ +// Builds ACP `ContentBlock[]` from a Fusion prompt. +// +// U3 core path: a plain string prompt becomes a single `{ type: "text", text }` +// block. The runtime may later pass structured content (e.g. an attached image); +// when present we emit the matching block. Keep this small and pure. + +import type { ContentBlock } from "@agentclientprotocol/sdk"; + +/** Optional structured content the runtime may attach alongside the text prompt. */ +export interface PromptImage { + /** Base64-encoded image data (no data: prefix). */ + data: string; + /** MIME type, e.g. "image/png". */ + mimeType: string; + /** Optional source URI for the image. */ + uri?: string; +} + +export interface BuildPromptOptions { + /** Image content to append as image block(s) after the text. */ + images?: PromptImage[]; +} + +/** + * Build the ACP prompt content blocks for a turn. + * + * A non-empty string yields one text block. An empty/whitespace-only string + * yields no text block (but any attached images are still included), so we never + * send a meaningless empty text block. Images, when supplied, are appended as + * `image` blocks (passthrough — KTD ContentBlock image variant). + */ +export function buildPromptBlocks(prompt: string, opts?: BuildPromptOptions): ContentBlock[] { + const blocks: ContentBlock[] = []; + + if (typeof prompt === "string" && prompt.trim().length > 0) { + blocks.push({ type: "text", text: prompt }); + } + + for (const image of opts?.images ?? []) { + blocks.push({ + type: "image", + data: image.data, + mimeType: image.mimeType, + ...(image.uri ? { uri: image.uri } : {}), + }); + } + + return blocks; +} diff --git a/plugins/fusion-plugin-omp-runtime/src/acp/provider.ts b/plugins/fusion-plugin-omp-runtime/src/acp/provider.ts new file mode 100644 index 0000000000..f80f5bcc4c --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/src/acp/provider.ts @@ -0,0 +1,541 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:GrokAcp 2026-07-11-16:00). */ +// ACP connection layer: spawn → ClientSideConnection → initialize handshake. +// +// U2 establishes the transport and completes the `initialize` handshake with +// integer protocol-version negotiation (KTD2) and a readiness timeout. Session +// driving (`session/new`, `session/prompt`, cancel, load) is U3 — this unit only +// exposes the live `conn` on the returned handle so later units can drive it. +// +// Security posture (KTD6): filesystem client capabilities are advertised ONLY +// when the caller's `advertiseFs` toggle is true — never hardcoded. Teardown is +// registry-SIGKILL-authoritative (KTD4a): `dispose()` force-kills the child via +// the process registry; that kill is the no-orphan guarantee, not a graceful +// round-trip. + +import { Readable, Writable } from "node:stream"; +import type { ChildProcess } from "node:child_process"; +import { + ClientSideConnection, + ndJsonStream, + PROTOCOL_VERSION, + type Agent, + type AgentCapabilities, + type Client, + type ContentBlock, + type RequestPermissionResponse, + type StopReason, +} from "@agentclientprotocol/sdk"; +import { spawnAgent, captureStderr, forceKill, unregisterProcess } from "./process-manager.js"; +import { createEventBridge } from "./event-bridge.js"; +import { resolvePermission, type ResolvePermissionOptions } from "./control-handler.js"; +import { createFsHandlers } from "./fs-capabilities.js"; +import { boundIdentifier } from "./sanitize.js"; +import type { AcpCallbacks, AcpMcpServer, PermissionGate } from "./types.js"; + +/** Options enabling the U7 fs client capabilities on the bridging handler. */ +export interface FsHandlerBuildOptions { + /** Confinement root — the session cwd / task worktree. */ + cwd: string; + /** Register `readTextFile` (advertised iff true). */ + allowRead: boolean; + /** Register `writeTextFile` (default OFF — KTD6; advertised iff true). */ + allowWrite: boolean; +} + +/** Default bound for the `initialize` handshake. */ +export const DEFAULT_INITIALIZE_TIMEOUT_MS = 30_000; + +/** Thrown when the agent negotiates an integer protocol version we don't support. */ +export class IncompatibleProtocolError extends Error { + readonly code = "incompatible_protocol" as const; + constructor( + readonly agentProtocolVersion: number, + readonly expected: number = PROTOCOL_VERSION, + ) { + super( + `ACP agent negotiated incompatible protocol version ${agentProtocolVersion} (client supports ${expected})`, + ); + this.name = "IncompatibleProtocolError"; + } +} + +/** Thrown when the `initialize` handshake does not complete within the bound. */ +export class HandshakeTimeoutError extends Error { + readonly code = "handshake_timeout" as const; + constructor(readonly timeoutMs: number) { + super(`ACP initialize handshake timed out after ${timeoutMs}ms`); + this.name = "HandshakeTimeoutError"; + } +} + +/** + * Minimal default client handler. Later units (U3/U4/U5/U7) supply the real one + * that bridges `session/update` into Fusion callbacks and routes permission + * requests through the action gate. The default cancels every permission request + * (never auto-allows an untrusted agent) and ignores updates. + */ +export function createDefaultClientHandler(): Client { + return { + async sessionUpdate() { + // no-op until the U4 event bridge is wired + }, + async requestPermission() { + return { outcome: { outcome: "cancelled" } }; + }, + }; +} + +/** A bridging client handler plus a drain control for its in-flight permissions. */ +export interface BridgingClientHandler { + /** The ACP `Client` impl handed to `ClientSideConnection`. */ + handler: Client; + /** + * Resolve every in-flight `requestPermission` with `{ cancelled }` and mark the + * handler cancelled so any request arriving afterward is answered cancelled + * immediately (U5 cancel-drain — KTD4a). Idempotent. + */ + cancelPending(): void; + /** + * Reset the event bridge's PER-TURN state (tool correlation, delta + * accumulators, cumulative-output counter, output-cap latch). MUST be called + * at the start of each prompt turn so a turn that trips the per-turn output cap + * does not silently suppress every subsequent turn (FIX 1). + */ + resetTurn(): void; +} + +/** + * The real client handler (U4 + U5): bridges every `session/update` notification + * into the engine callbacks, AND answers `session/request_permission` through the + * per-category action gate (U5 — the SECURITY FLOOR). + * + * Permission requests are routed to `resolvePermission`, which classifies each + * call per-category against the live `gate` and selects `allow_once` only (never + * `*_always`). When no `gate` is supplied the resolver default-denies. + * + * Cancel-drain (KTD4a / Risk: in-flight permission deadlock): every pending + * `requestPermission` promise is tracked; `cancelPending()` resolves them all + * with `{ cancelled }`. A request that arrives AFTER cancel is answered + * `{ cancelled }` immediately so the agent never blocks on teardown. + */ +export function createBridgingClientHandler( + callbacks: AcpCallbacks, + gate?: PermissionGate, + fsOpts?: FsHandlerBuildOptions, + permissionOpts?: ResolvePermissionOptions, +): BridgingClientHandler { + const bridge = createEventBridge(callbacks); + + // U7: build the fs handlers, returning only the enabled ones. They are added + // to the handler below ONLY when present, keeping the advertised-capability / + // registered-handler invariant consistent (KTD6). + const fsHandlers = fsOpts + ? createFsHandlers({ + cwd: fsOpts.cwd, + gate, + allowRead: fsOpts.allowRead, + allowWrite: fsOpts.allowWrite, + allowUnrestricted: permissionOpts?.allowUnrestricted, + }) + : {}; + + const cancelledResponse: RequestPermissionResponse = { + outcome: { outcome: "cancelled" }, + }; + + let cancelled = false; + // Each entry resolves its pending requestPermission with a cancelled outcome. + const pending = new Set<(response: RequestPermissionResponse) => void>(); + + function cancelPending(): void { + cancelled = true; + for (const resolveCancelled of [...pending]) { + resolveCancelled(cancelledResponse); + } + pending.clear(); + } + + const handler: Client = { + async sessionUpdate(params) { + bridge.handleSessionUpdate(params.update); + }, + async requestPermission(params): Promise { + // A request arriving after cancel is answered cancelled immediately. + if (cancelled) return cancelledResponse; + + // Race the real gate resolution against a cancel-drain so an in-flight + // request is answered the moment teardown drains it (never deadlocks). + return await new Promise((resolve) => { + let settled = false; + const finish = (response: RequestPermissionResponse) => { + if (settled) return; + settled = true; + pending.delete(drain); + resolve(response); + }; + const drain = (response: RequestPermissionResponse) => finish(response); + pending.add(drain); + + resolvePermission(params.toolCall, params.options, gate, permissionOpts).then( + (response) => finish(response), + // resolvePermission never rejects, but stay safe: deny-by-cancel. + () => finish(cancelledResponse), + ); + }); + }, + }; + + // Register fs handlers ONLY when enabled, so the advertised capability and the + // present handler stay consistent (KTD6). If a capability is disabled the + // method is absent → an agent calling it gets a JSON-RPC method-not-found + // error (never a silent success). + if (fsHandlers.readTextFile) handler.readTextFile = fsHandlers.readTextFile; + if (fsHandlers.writeTextFile) handler.writeTextFile = fsHandlers.writeTextFile; + + return { handler, cancelPending, resetTurn: () => bridge.reset() }; +} + +export interface AcpConnection { + /** Live ACP connection — later units drive session/new, prompt, cancel, load. */ + conn: ClientSideConnection; + child: ChildProcess; + agentCapabilities?: AgentCapabilities; + /** Auth methods the agent advertised; non-empty means auth is required. */ + authMethods: Array<{ id: string }>; + /** Current redacted stderr buffer. */ + stderr(): string; + /** Force-kill the agent via the registry (KTD4a — SIGKILL is authoritative). */ + dispose(): void; +} + +export interface ConnectOptions { + binaryPath: string; + args: string[]; + cwd: string; + env: NodeJS.ProcessEnv; + clientHandler?: Client; + /** Advertise fs capabilities ONLY where the toggle is true (KTD6). */ + advertiseFs: { read: boolean; write: boolean }; + initializeTimeoutMs?: number; + /** + * FNXC:GrokAcp 2026-07-11-15:00: + * Optional post-initialize authenticate (xAI Grok docs: initialize → authenticate + * → session/new). Prefer methods listed in preferMethods that the agent + * advertised; when require is true, missing auth fails closed. + * See https://docs.x.ai/build/cli/headless-scripting#acp + */ + authenticate?: { + preferMethods?: string[]; + methodId?: string; + meta?: Record; + require?: boolean; + }; +} + +function withTimeout(promise: Promise, ms: number, onTimeout: () => Error): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(onTimeout()), ms); + timer.unref?.(); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (err) => { + clearTimeout(timer); + reject(err); + }, + ); + }); +} + +/** + * Spawn the agent, establish a `ClientSideConnection` over its stdio, and + * complete the `initialize` handshake under a timeout. + * + * Throws `HandshakeTimeoutError` on timeout, `IncompatibleProtocolError` when + * the negotiated integer protocol version mismatches — in both cases the + * subprocess is force-killed before throwing (no orphans, KTD4a). On `initialize` + * the fs capability flags are gated by `advertiseFs` and never hardcoded (KTD6). + */ +export async function connect(opts: ConnectOptions): Promise { + const timeoutMs = opts.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS; + const child = spawnAgent({ + binaryPath: opts.binaryPath, + args: opts.args, + cwd: opts.cwd, + env: opts.env, + }); + const stderr = captureStderr(child); + + let disposed = false; + const dispose = () => { + if (disposed) return; + disposed = true; + forceKill(child); + unregisterProcess(child); + }; + + // If the binary is missing, spawn emits "error" asynchronously. Surface that + // as a rejection of the handshake rather than an unhandled event-loop error. + let spawnError: Error | undefined; + const spawnErrored = new Promise((_resolve, reject) => { + child.once("error", (err: Error) => { + spawnError = err; + reject(err); + }); + }); + // Avoid an unhandled rejection if the handshake resolves/throws first. + spawnErrored.catch(() => undefined); + + // output = the agent's stdin; input = the agent's stdout. + const stream = ndJsonStream( + Writable.toWeb(child.stdin!) as unknown as WritableStream, + Readable.toWeb(child.stdout!) as unknown as ReadableStream, + ); + + const handler = opts.clientHandler ?? createDefaultClientHandler(); + const conn = new ClientSideConnection((_agent: Agent) => handler, stream); + + let initResult: Awaited>; + try { + initResult = await Promise.race([ + withTimeout( + conn.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: { + fs: { + readTextFile: opts.advertiseFs.read === true, + writeTextFile: opts.advertiseFs.write === true, + }, + }, + }), + timeoutMs, + () => new HandshakeTimeoutError(timeoutMs), + ), + spawnErrored, + ]); + } catch (err) { + dispose(); + if (spawnError && err === spawnError) throw spawnError; + throw err; + } + + // Compare the negotiated integer protocol version; do NOT assume the agent + // errors first (KTD2). + if (initResult.protocolVersion !== PROTOCOL_VERSION) { + dispose(); + throw new IncompatibleProtocolError(initResult.protocolVersion); + } + + const authMethods = Array.isArray(initResult.authMethods) + ? initResult.authMethods.map((m) => ({ id: m.id })) + : []; + + /* + FNXC:GrokAcp 2026-07-11-15:00: + Official Grok ACP scripting requires authenticate after initialize (method + xai.api_key when XAI_API_KEY is set, else cached_token) with + `_meta: { headless: true }` before session/new. Generic ACP agents that + advertise no preferred methods skip this step. + */ + if (opts.authenticate) { + try { + await authenticateAcpConnection( + { conn, authMethods }, + opts.authenticate, + ); + } catch (err) { + dispose(); + throw err; + } + } + + return { + conn, + child, + agentCapabilities: initResult.agentCapabilities, + authMethods, + stderr, + dispose, + }; +} + +export class AcpAuthRequiredError extends Error { + readonly code = "acp_auth_required" as const; + constructor(readonly availableMethodIds: string[]) { + super( + availableMethodIds.length > 0 + ? `ACP agent requires authentication but no preferred method matched (available: ${availableMethodIds.join(", ")})` + : "ACP agent requires authentication but advertised no auth methods", + ); + this.name = "AcpAuthRequiredError"; + } +} + +/** + * Call ACP `authenticate` with the first preferred method the agent advertised. + * No-ops when neither methodId nor a preferred method is available and require + * is false. + */ +export async function authenticateAcpConnection( + connection: Pick, + opts: { + preferMethods?: string[]; + methodId?: string; + meta?: Record; + require?: boolean; + }, +): Promise<{ methodId: string } | undefined> { + const available = connection.authMethods.map((m) => m.id); + const availableSet = new Set(available); + let methodId = opts.methodId?.trim(); + if (methodId && !availableSet.has(methodId)) { + methodId = undefined; + } + if (!methodId) { + for (const candidate of opts.preferMethods ?? []) { + if (availableSet.has(candidate)) { + methodId = candidate; + break; + } + } + } + if (!methodId) { + if (opts.require) { + throw new AcpAuthRequiredError(available); + } + return undefined; + } + await connection.conn.authenticate({ + methodId, + _meta: opts.meta ?? { headless: true }, + }); + return { methodId }; +} + +// --- U3: session driving on top of connect() ------------------------------- +// +// These helpers wrap the `ClientSideConnection` session methods so the runtime +// adapter drives one shape (open → prompt → cancel/resume) without touching SDK +// types directly. mcpServers default to [] (no tools); callers may forward a +// non-empty list for operator/Fusion MCP (U10). + +function readsLoadSession(connection: AcpConnection): boolean { + // `agentCapabilities` is already typed as `AgentCapabilities | undefined`. + return connection.agentCapabilities?.loadSession === true; +} + +export interface NewAcpSessionResult { + sessionId: string; + /** Initial session mode state, when the agent reports one. */ + modes?: unknown; +} + +/** + * Open a fresh ACP session via `session/new`. Forwards `opts.mcpServers` (U10 — + * Route A): when present and non-empty, the agent can call those Fusion tools and + * each call still routes through the U5 permission floor. Defaults to `[]` so + * Route B read-only ask turns keep their no-tools posture. + */ +export async function newAcpSession( + connection: AcpConnection, + opts: { + cwd: string; + mcpServers?: AcpMcpServer[]; + /** + * FNXC:GrokAcp 2026-07-11-14:00: + * Optional ACP `_meta` bag for agent-specific session setup (Grok uses + * `pluginDirs`, `rules`, `systemPromptOverride`). Opaque to the generic + * ACP client — agents interpret their own keys. + */ + meta?: Record; + }, +): Promise { + const res = await connection.conn.newSession({ + cwd: opts.cwd, + mcpServers: (opts.mcpServers ?? []) as never, + ...(opts.meta && Object.keys(opts.meta).length > 0 ? { _meta: opts.meta } : {}), + }); + // `sessionId` is agent-supplied/untrusted (U6/Risk S7): bound its length and + // strip path separators / NUL bytes before it is stored on the session or + // could ever touch a resume-file path. + return { sessionId: boundIdentifier(res.sessionId), modes: res.modes ?? undefined }; +} + +/** + * Send a prompt turn via `session/prompt` and return the terminal `stopReason`. + * + * The SDK prompt promise resolves only AFTER every `session/update` for the turn + * has been delivered to the client handler — so resolving here is the correct + * "turn complete" signal (no extra draining required). + */ +export async function promptAcpSession( + connection: AcpConnection, + sessionId: string, + blocks: ContentBlock[], +): Promise { + const res = await connection.conn.prompt({ sessionId, prompt: blocks }); + return res.stopReason; +} + +/** + * Best-effort cancel of the active turn via the `session/cancel` notification. + * + * This is fire-and-forget (no ack in the protocol). Errors are swallowed — it + * runs during teardown where the registry SIGKILL is the authoritative guarantee + * (KTD4a). + */ +/** Upper bound on how long `cancelAcpSession` waits on the cancel write (FIX 7). */ +const CANCEL_TIMEOUT_MS = 2_000; + +export async function cancelAcpSession( + connection: AcpConnection, + sessionId: string, +): Promise { + // `conn.cancel` writes to the agent's stdin pipe; a dead or full pipe can + // back-pressure and stall teardown (the adapter awaits this BEFORE the + // authoritative registry SIGKILL). Bound it so the kill still runs promptly + // (FIX 7). Errors are swallowed — this is already best-effort. + try { + await Promise.race([ + connection.conn.cancel({ sessionId }), + new Promise((resolve) => { + const timer = setTimeout(resolve, CANCEL_TIMEOUT_MS); + timer.unref?.(); + }), + ]); + } catch { + // fire-and-forget; teardown's SIGKILL is authoritative + } +} + +/** + * Resume a session. Prefers `session/load` (history replay) when the agent + * advertised the `loadSession` capability; otherwise falls back to opening a + * fresh `session/new`. There is no separate `resume` method in this SDK build — + * `loadSession` IS the resume path. + * + * NOTE (v1): engine-driven resume wiring is intentionally deferred — the + * runtime adapter always opens a fresh session via `newAcpSession`. This helper + * exists (and is unit-tested for the id-sanitization invariant) so resume can be + * wired in by passing a `sessionId` through `AgentRuntimeOptions` later without + * building new resume machinery. + */ +export async function loadAcpSession( + connection: AcpConnection, + opts: { sessionId: string; cwd: string }, +): Promise { + if (readsLoadSession(connection)) { + // Bound the (agent-originated) resume id before it is used as a protocol / + // potential path component (U6/Risk S7). + const safeId = boundIdentifier(opts.sessionId); + const res = await connection.conn.loadSession({ + sessionId: safeId, + cwd: opts.cwd, + mcpServers: [], + }); + return { sessionId: safeId, modes: res.modes ?? undefined }; + } + return newAcpSession(connection, { cwd: opts.cwd }); +} diff --git a/plugins/fusion-plugin-omp-runtime/src/acp/runtime-adapter.ts b/plugins/fusion-plugin-omp-runtime/src/acp/runtime-adapter.ts new file mode 100644 index 0000000000..cb9b21df50 --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/src/acp/runtime-adapter.ts @@ -0,0 +1,191 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:GrokAcp 2026-07-11-16:00). */ +// AgentRuntime adapter for the ACP runtime. +// +// U3 implements the real session lifecycle: createSession spawns + handshakes +// (U2 connect()) then opens a `session/new`; promptWithFallback drives one +// prompt turn to its terminal stopReason; dispose tears down the connection +// (KTD4a — registry SIGKILL is authoritative). The `session/update` event +// bridge (U4) and the permission gate (U5) are wired in later units; for U3 the +// default client handler from U2 is used and a turn still resolves with a +// stopReason. + +import { resolveCliSettings, type AcpCliSettings } from "./cli-spawn.js"; +import { + connect, + newAcpSession, + promptAcpSession, + cancelAcpSession, + createBridgingClientHandler, +} from "./provider.js"; +import { buildSpawnEnv } from "./process-manager.js"; +import { buildPromptBlocks } from "./prompt-builder.js"; +import type { + AgentRuntime, + AgentRuntimeOptions, + AgentSession, + AgentSessionResult, + AcpSession, +} from "./types.js"; + +export class AcpRuntimeAdapter implements AgentRuntime { + readonly id = "acp"; + readonly name = "ACP Runtime"; + private readonly settings: AcpCliSettings; + + constructor(settings?: Record) { + this.settings = resolveCliSettings(settings); + } + + async createSession(options: AgentRuntimeOptions): Promise { + const model = this.settings.model ?? options.defaultModelId ?? "acp"; + + // Bridge streamed `session/update` notifications onto the engine callbacks + // (U4) so ACP agents render like existing runtimes. + const callbacks = { + onText: options.onText, + onThinking: options.onThinking, + onToolStart: options.onToolStart, + onToolEnd: options.onToolEnd, + }; + + // Build the bridging client handler with the per-run permission gate (U5): + // its `requestPermission` classifies each call per-category against the live + // gate (KTD3a) and selects `allow_once` only (S2). `cancelPending` drains + // in-flight permission requests on teardown so the agent never deadlocks. + // fs client capabilities (U7) are gated by settings — reads opt-in, writes + // default OFF (KTD6) — and confined to the task cwd by the path jail. The + // same toggles drive the advertised `fs` capability in connect() below, so + // advertisement and registered handlers stay consistent. + const { handler: clientHandler, cancelPending, resetTurn } = createBridgingClientHandler( + callbacks, + options.actionGateContext, + { + cwd: options.cwd, + allowRead: this.settings.fsRead, + allowWrite: this.settings.fsWrite, + }, + // Risk S1: unless the user acknowledged the untrusted-agent risk, a blanket + // `allow` on a sensitive category is escalated to approval rather than + // auto-approved — so the default `unrestricted` policy can't silently + // green-light this untrusted subprocess. + { allowUnrestricted: this.settings.allowUnrestricted }, + ); + + // Spawn + initialize (U2). fs capabilities are advertised only where the + // resolved settings enable them (KTD6); the subprocess env is built from the + // allow-list, never inherited process.env (KTD6b). + // Optional authenticate (Grok headless ACP: initialize → authenticate → session/new). + const connection = await connect({ + binaryPath: this.settings.binaryPath, + args: this.settings.args, + cwd: options.cwd, + env: buildSpawnEnv(this.settings.envAllowList, { required: this.settings.requiredEnv }), + advertiseFs: { read: this.settings.fsRead, write: this.settings.fsWrite }, + clientHandler, + ...(this.settings.authenticate ? { authenticate: this.settings.authenticate } : {}), + }); + + // Open the ACP session over the task worktree. Forward MCP servers when the + // caller supplied them (U10 — Route A); absent/empty keeps the Route B + // read-only ask posture. Tool calls still route through the U5 permission floor. + // + // FNXC:GrokAcp 2026-07-11-14:00: + // Callers (Grok runtime) may also pass `_meta` (pluginDirs / rules / + // systemPromptOverride) via options.sessionMeta so agent-specific skill and + // prompt setup rides on session/new without a second protocol hop. + let sessionId: string; + try { + const sessionMeta = + options && typeof options === "object" && "sessionMeta" in options + ? (options as { sessionMeta?: Record }).sessionMeta + : undefined; + const opened = await newAcpSession(connection, { + cwd: options.cwd, + mcpServers: options.mcpServers, + meta: sessionMeta, + }); + sessionId = opened.sessionId; + } catch (err) { + // Don't leak the subprocess if session/new fails after a good handshake. + connection.dispose(); + throw err; + } + + let disposed = false; + const session: AcpSession = { + model, + systemPrompt: options.systemPrompt, + sessionId, + cwd: options.cwd, + lastModelDescription: `acp/${model}`, + callbacks, + // Persist the per-run gate (KTD3) so U5/U7 can reach the live action gate. + gate: options.actionGateContext, + connection, + // Reset the event bridge's per-turn state at the start of each turn so a + // turn that trips the per-turn output cap can't latch and suppress every + // subsequent turn (FIX 1). + resetTurn, + dispose: () => { + if (disposed) return; + disposed = true; + // Drain in-flight permission requests BEFORE the registry kill so a + // blocked agent is released (KTD4a — the SIGKILL is still authoritative). + cancelPending(); + connection.dispose(); + }, + }; + + return { session }; + } + + async promptWithFallback( + session: AgentSession, + prompt: string, + _options?: unknown, + ): Promise<{ stopReason?: string }> { + const acp = session as AcpSession; + if (!acp.connection) { + throw new Error("ACP session has no live connection (createSession not completed)"); + } + // Clear per-turn event-bridge state BEFORE driving the turn so tool + // correlation, delta accumulators, and the output-cap latch all start clean + // each turn (FIX 1). Without this, a turn that hit the per-turn output cap + // would silently suppress all later turns. + acp.resetTurn?.(); + const blocks = buildPromptBlocks(prompt); + // Resolve when the SDK prompt promise resolves — it already drains all + // session/update notifications for the turn before reporting the stopReason. + // The bridging client handler installed at createSession (U4) has already + // surfaced streamed text/thinking/tool updates onto session.callbacks. + /* + FNXC:ACP-RouteB 2026-06-14-20:09: + Route-B validation must distinguish clean end_turn answers from truncated or cancelled turns. Surface ACP stopReason to the engine runner instead of discarding it so callers can reject syntactically complete JSON recovered from incomplete output. + */ + const stopReason = await promptAcpSession(acp.connection, acp.sessionId, blocks); + return { stopReason }; + } + + describeModel(session: AgentSession): string { + return session.lastModelDescription || "acp"; + } + + async dispose(session: AgentSession): Promise { + // KTD4a teardown: best-effort cancel of any in-flight turn, then force the + // connection down. The process-registry SIGKILL is the authoritative + // no-orphan guarantee, not the cancel round-trip. Idempotent. + // + // FNXC:OmpAcp 2026-07-13-23:10: + // Always run session.dispose() even when cancel rejects, so SIGKILL teardown is not skipped. + const acp = session as AcpSession; + try { + if (acp.connection && acp.sessionId) { + await cancelAcpSession(acp.connection, acp.sessionId); + } + } catch { + // best-effort cancel only + } finally { + session.dispose(); + } + } +} diff --git a/plugins/fusion-plugin-omp-runtime/src/acp/sanitize.ts b/plugins/fusion-plugin-omp-runtime/src/acp/sanitize.ts new file mode 100644 index 0000000000..6b5aef34c6 --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/src/acp/sanitize.ts @@ -0,0 +1,81 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:GrokAcp 2026-07-11-16:00). */ +// Untrusted-input sanitization helpers (U6 / Risk S7). +// +// Every string an ACP agent emits — text/thinking deltas, tool `title`, plan +// text, `sessionId`, `toolCallId` — is untrusted input. Before any such string +// reaches a Fusion callback, a log, the UI, or (worst) a filesystem path, it must +// be neutralized: +// +// - `stripControlSequences` removes ANSI/OSC escapes and C0/C1 control chars so +// a crafted string cannot inject terminal escapes / rewrite log lines. +// - `boundString` truncates oversized content (Risk S5) with a visible marker. +// - `boundIdentifier` bounds an agent-supplied id and strips path separators / +// NUL bytes so the id can never be interpolated into a filesystem path +// unsanitized. + +/** Default cap for an agent-supplied identifier (sessionId, toolCallId). */ +export const DEFAULT_IDENTIFIER_MAX = 256; + +/** Marker appended when `boundString` truncates its input. */ +export const TRUNCATION_MARKER = "…[truncated]"; + +// ANSI escape sequences: +// CSI / SGR: ESC [ ... +// OSC: ESC ] ... (BEL | ST) +// other ESC-prefixed two-char sequences (e.g. ESC ( B) +const ANSI_PATTERN = + // eslint-disable-next-line no-control-regex + /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]|\x1b\[[0-?]*[ -/]*[@-~]|\x1b[ -/]*[0-~]/g; + +// Non-printable control chars to drop. C0 = \x00–\x1F, DEL = \x7F, C1 = \x80–\x9F. +// We KEEP \n (\x0A) and \t (\x09) — they are legitimate whitespace in agent text. +// eslint-disable-next-line no-control-regex +const CONTROL_CHARS_PATTERN = /[\x00-\x08\x0B-\x1F\x7F-\x9F]/g; + +/** + * Remove ANSI escape sequences (CSI/SGR/OSC) and non-printable C0/C1 control + * characters from an untrusted string. Preserves `\n` and `\t`. Never throws — + * a non-string input yields an empty string. + */ +export function stripControlSequences(text: string): string { + if (typeof text !== "string" || text === "") return ""; + return text.replace(ANSI_PATTERN, "").replace(CONTROL_CHARS_PATTERN, ""); +} + +/** + * Truncate `text` to at most `max` characters, appending a short truncation + * marker when the input is cut. A non-positive `max` yields an empty string; a + * non-string input yields an empty string. The returned string is never longer + * than `max` (the marker replaces the tail of the budget, it is not added on + * top). + */ +export function boundString(text: string, max: number): string { + if (typeof text !== "string" || text === "") return ""; + if (!Number.isFinite(max) || max <= 0) return ""; + if (text.length <= max) return text; + if (max <= TRUNCATION_MARKER.length) { + return text.slice(0, max); + } + return text.slice(0, max - TRUNCATION_MARKER.length) + TRUNCATION_MARKER; +} + +/** + * Bound an agent-supplied identifier to a sane length and strip anything that + * could let it escape into a filesystem path: path separators (`/`, `\`), NUL + * bytes, control chars, and `..` traversal segments are removed. The result is + * a flat, length-bounded token safe to use as a Map key or a single path + * component. A non-string / empty input yields `""`. + */ +export function boundIdentifier(id: string, max: number = DEFAULT_IDENTIFIER_MAX): string { + if (typeof id !== "string" || id === "") return ""; + const cap = Number.isFinite(max) && max > 0 ? max : DEFAULT_IDENTIFIER_MAX; + // Drop ANSI/control first, then path-dangerous characters, then traversal. + let cleaned = stripControlSequences(id) + // eslint-disable-next-line no-control-regex + .replace(/\x00/g, "") + .replace(/[/\\]/g, "_"); + // Collapse any remaining `..` traversal tokens (after separators were removed + // a `..` cannot point anywhere, but normalize it away for defense in depth). + cleaned = cleaned.replace(/\.\.+/g, "_"); + return cleaned.slice(0, cap); +} diff --git a/plugins/fusion-plugin-omp-runtime/src/acp/tool-mapping.ts b/plugins/fusion-plugin-omp-runtime/src/acp/tool-mapping.ts new file mode 100644 index 0000000000..031a08a693 --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/src/acp/tool-mapping.ts @@ -0,0 +1,47 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:GrokAcp 2026-07-11-16:00). */ +// Pure helpers mapping ACP `ToolCall` metadata into the display name + args +// shape Fusion's `onToolStart`/`onToolEnd` callbacks expect. +// +// ACP's `kind` is agent-defined, optional, and partial (U4). These helpers must +// never throw on missing/odd input — a missing title falls back to a label +// derived from `kind`, and a missing/non-object `rawInput` normalizes to `{}`. + +import type { ToolKind } from "@agentclientprotocol/sdk"; + +/** Human-readable labels for each ACP `ToolKind`. */ +const KIND_LABELS: Record = { + read: "Read", + edit: "Edit", + delete: "Delete", + move: "Move", + search: "Search", + execute: "Execute", + think: "Think", + fetch: "Fetch", + switch_mode: "Switch Mode", + other: "Tool", +}; + +/** + * Resolve a display name for a tool call. Prefers the agent-supplied `title`; + * falls back to a label derived from `kind`; final fallback is `"tool"`. + */ +export function toolDisplayName(toolCall: { title?: string | null; kind?: ToolKind | null }): string { + const title = typeof toolCall.title === "string" ? toolCall.title.trim() : ""; + if (title) return title; + const kind = toolCall.kind; + if (kind && kind in KIND_LABELS) return KIND_LABELS[kind]; + return "tool"; +} + +/** + * Normalize a tool call's `rawInput` to a plain object. Returns `{}` when the + * input is undefined, null, or any non-object (arrays included) so downstream + * code can always treat args as a record. + */ +export function normalizeToolArgs(rawInput: unknown): Record { + if (rawInput === null || typeof rawInput !== "object" || Array.isArray(rawInput)) { + return {}; + } + return rawInput as Record; +} diff --git a/plugins/fusion-plugin-omp-runtime/src/acp/types.ts b/plugins/fusion-plugin-omp-runtime/src/acp/types.ts new file mode 100644 index 0000000000..5bbc006e7c --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/src/acp/types.ts @@ -0,0 +1,189 @@ +/* Vendored ACP client from fusion-plugin-acp-runtime — see ./VENDORED.md (FNXC:GrokAcp 2026-07-11-16:00). */ +// Local types for the ACP (Agent Client Protocol) runtime plugin. +// +// The wire protocol types come from `@agentclientprotocol/sdk` (the `schema` +// namespace). These local types describe (a) the Fusion `AgentRuntime` contract +// this plugin implements and (b) the ACP session state this plugin tracks. +// +// The `AgentRuntimeOptions` here is a plugin-local structural copy of the engine +// contract (`packages/engine/src/agent-runtime.ts`). It deliberately includes +// only the fields this runtime reads. `actionGateContext` is the engine-populated +// per-run permission gate — see `PermissionGate` below, the narrow structural +// view this plugin couples to instead of importing `@fusion/engine` internals. + +import type { AcpConnection } from "./provider.js"; + +/** Callbacks the engine wires to surface streamed agent output into Fusion's UI/logs. */ +export interface AcpCallbacks { + onText?: (text: string) => void; + onThinking?: (text: string) => void; + onToolStart?: (toolName: string, args?: unknown) => void; + onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void; +} + +/** + * MCP servers forwarded to the agent on `session/new` (U10 — Route A). + * `env` / `headers` are explicit name/value pairs; inherited `process.env` is + * NEVER forwarded to the untrusted agent. + * + * FNXC:GrokAcp 2026-07-11-14:00: + * Widen beyond stdio so Grok ACP can receive Fusion operator MCP servers over + * http/sse (Grok advertises mcpCapabilities.http/sse) as well as the classic + * stdio custom-tools bridge used by Route A. + */ +export interface AcpMcpServerStdio { + name: string; + command: string; + args: string[]; + env: { name: string; value: string }[]; +} + +export interface AcpMcpServerHttp { + type: "http"; + name: string; + url: string; + headers: { name: string; value: string }[]; +} + +export interface AcpMcpServerSse { + type: "sse"; + name: string; + url: string; + headers: { name: string; value: string }[]; +} + +export type AcpMcpServer = AcpMcpServerStdio | AcpMcpServerHttp | AcpMcpServerSse; + +/** Per-category permission disposition (mirrors the engine policy shape). */ +export type GateDisposition = "allow" | "block" | "require-approval"; + +/** + * Fusion action-gate categories — the full policy-rule keyspace, used to read + * `permissionPolicy.rules[category]`. `"exempt"` is implicit (read-only / benign) + * and always allows. + * + * Note: ACP's `ToolKind` has no git/task discriminator, so `classifyToolKind` + * only ever produces `file_write_delete` / `command_execution` / `network_api` + * (+ exempt). `git_write` and `task_agent_mutation` remain part of the category + * type because the policy rules are keyed by all categories — git writes in + * particular route through `file_write_delete` gating PLUS the path-jail's hard + * `.git/**` reject (KTD6a), not a dedicated `git_write` classification. + */ +export type FusionCategory = + | "git_write" + | "file_write_delete" + | "command_execution" + | "network_api" + | "task_agent_mutation"; + +/** Approval lifecycle status as returned by the gate's lookup closure. */ +export type ApprovalStatus = "pending" | "approved" | "denied" | "completed"; + +/** + * Narrow structural view of the engine's `AgentActionGateContext` + * (`packages/engine/src/agent-action-gate.ts`). The plugin reads only these + * members; typing them locally avoids a hard dependency on `@fusion/engine`. + * + * `permissionPolicy.rules` is the per-category disposition map the U5 floor + * consults — NEVER a preset id (S1/KTD3a). All HITL closures except + * `createApprovalRequest` are optional: when the HITL machinery is absent, the + * permission floor (U5) default-denies `require-approval` categories rather than + * throwing (Risk S1). + */ +export interface PermissionGate { + permissionPolicy?: { + rules?: Record; + }; + /** Register an approval request; returns the created record (with an `id`). */ + createApprovalRequest?: ( + decision: unknown, + args: Record, + ) => Promise | unknown; + /** Look up a prior decision by dedupe key (decision reuse). */ + findApprovalByDedupeKey?: ( + dedupeKey: string, + ) => Promise<{ id: string; status: ApprovalStatus } | null> | { id: string; status: ApprovalStatus } | null; + /** Block until the human resolves the referenced approval request. */ + pauseForApproval?: (info: { + approvalRequestId: string; + decision: unknown; + }) => Promise | void; + /** Mark an approval request finalized after the decision is consumed. */ + markApprovalCompleted?: (approvalRequestId: string) => Promise | void; +} + +/** Plugin-local copy of the engine's AgentRuntimeOptions (subset this runtime reads). */ +export interface AgentRuntimeOptions { + cwd: string; + systemPrompt: string; + tools?: "coding" | "readonly"; + onText?: (text: string) => void; + onThinking?: (text: string) => void; + onToolStart?: (toolName: string, args?: unknown) => void; + onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void; + defaultProvider?: string; + defaultModelId?: string; + defaultThinkingLevel?: string; + /** Per-run permission gate, populated by the engine. See PermissionGate. */ + actionGateContext?: PermissionGate; + /** + * MCP servers to forward on `session/new` (U10 — Route A). When present and + * non-empty, the agent can call these tools (each call still routes through the + * U5 permission floor). Absent/empty preserves Route B's read-only ask posture. + */ + mcpServers?: AcpMcpServer[]; + /** + * FNXC:GrokAcp 2026-07-11-14:00: + * Opaque ACP `session/new._meta` for agent-specific setup (Grok pluginDirs / + * rules / systemPromptOverride). Ignored by agents that do not read `_meta`. + */ + sessionMeta?: Record; +} + +/** Live ACP session state tracked by the runtime adapter. */ +export interface AcpSession { + /** Model/agent identifier resolved for this session. */ + model: string; + systemPrompt: string; + /** ACP session id returned by `session/new` (empty until established). */ + sessionId: string; + /** Working directory the agent operates over (the task worktree). */ + cwd: string; + lastModelDescription: string; + callbacks: AcpCallbacks; + /** Per-run permission gate captured at createSession (U5/U7 read this). */ + gate?: PermissionGate; + /** + * Live ACP connection backing this session (U3). Prompt/dispose reach the + * agent through it. Undefined only for the bare session shell used in tests. + */ + connection?: AcpConnection; + /** + * Reset the event bridge's per-turn state (tool correlation, delta + * accumulators, output-cap latch). Called by `promptWithFallback` at the start + * of each turn (FIX 1). Undefined for the bare session shell used in tests. + */ + resetTurn?: () => void; + dispose(): void; +} + +export type AgentSession = AcpSession; + +export interface AgentPromptResult { + stopReason?: string; +} + +export interface AgentSessionResult { + session: AgentSession; + sessionFile?: string; +} + +/** The Fusion runtime contract this plugin implements (mirrors the engine interface). */ +export interface AgentRuntime { + id: string; + name: string; + createSession(options: AgentRuntimeOptions): Promise; + promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise; + describeModel(session: AgentSession): string; + dispose?(session: AgentSession): Promise; +} diff --git a/plugins/fusion-plugin-omp-runtime/src/cli-spawn.ts b/plugins/fusion-plugin-omp-runtime/src/cli-spawn.ts new file mode 100644 index 0000000000..46998c50e6 --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/src/cli-spawn.ts @@ -0,0 +1,61 @@ +import { spawn } from "node:child_process"; + +function formatSpawnError(error: Error & { code?: unknown }): string { + const code = typeof error.code === "string" ? `${error.code}: ` : ""; + return `spawn error: ${code}${error.message}`.trim(); +} + +export async function runOmpCommand( + binary: string, + args: string[], + timeoutMs: number, +): Promise<{ code: number | null; stdout: string; stderr: string }> { + return new Promise((resolve) => { + let stdout = ""; + let stderr = ""; + let settled = false; + let timer: NodeJS.Timeout | undefined; + + const finish = (result: { code: number | null; stdout: string; stderr: string }) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + resolve(result); + }; + + /* + FNXC:OmpAcp 2026-07-11-23:35: + Windows installers/npm-style shims can expose `omp.cmd` or `omp.bat` on PATH; + Node cannot direct-spawn those batch wrappers without the command shell. + Keep Unix/macOS on direct spawn. + */ + const child = spawn(binary, args, { + stdio: ["ignore", "pipe", "pipe"], + shell: process.platform === "win32", + }); + + timer = setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { + // best effort + } + finish({ code: 124, stdout, stderr }); + }, timeoutMs); + + child.stdout?.on("data", (c: Buffer) => { + stdout += c.toString("utf-8"); + }); + child.stderr?.on("data", (c: Buffer) => { + stderr += c.toString("utf-8"); + }); + child.once("error", (error: Error & { code?: unknown }) => { + const diagnostic = formatSpawnError(error); + stderr = stderr ? `${stderr}\n${diagnostic}` : diagnostic; + finish({ code: 127, stdout, stderr }); + }); + child.once("close", (code) => { + finish({ code, stdout, stderr }); + }); + }); +} diff --git a/plugins/fusion-plugin-omp-runtime/src/index.ts b/plugins/fusion-plugin-omp-runtime/src/index.ts new file mode 100644 index 0000000000..3dc8bfa9b9 --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/src/index.ts @@ -0,0 +1,104 @@ +import { definePlugin } from "@fusion/plugin-sdk"; +import type { FusionPlugin } from "@fusion/plugin-sdk"; +import { killAllProcesses } from "./acp/index.js"; +import { probeOmpBinary } from "./probe.js"; +import { discoverOmpProviderModels } from "./provider.js"; +import { OmpRuntimeAdapter } from "./runtime-adapter.js"; + +/* +FNXC:OmpAcp 2026-07-11-23:35: +OMP Runtime plugin — drive Oh My Pi (`omp`) as a Fusion agent runtime over the +Agent Client Protocol. Transport is `omp acp` (JSON-RPC/stdio). Mirrors the +landed Grok ACP runtime pattern (vendored ACP client under ./acp/) with +OMP-specific binary/args/auth (agent auth reuses ~/.omp). + +This shells out to an operator-installed `omp` binary on PATH — Fusion does not +download or bundle it. Upstream: https://omp.sh/docs/acp +https://github.com/can1357/oh-my-pi +*/ + +// Reap OMP ACP agent subprocesses on hard process exit (registry SIGKILL is +// authoritative). Scoped to ACP-tracked agent children only — never port 4040. +process.on("exit", killAllProcesses); + +const plugin: FusionPlugin = definePlugin({ + manifest: { + id: "fusion-plugin-omp-runtime", + name: "OMP Runtime Plugin", + version: "0.1.0", + description: "Oh My Pi (omp) runtime support for Fusion via ACP (omp acp)", + author: "Fusion Team", + homepage: "https://omp.sh/docs/acp", + runtime: { + runtimeId: "omp", + name: "OMP Runtime", + version: "0.1.0", + description: "Drives the local `omp acp` Agent Client Protocol server", + }, + }, + state: "installed", + hooks: { + onLoad: (ctx) => { + ctx.logger.info( + "OMP Runtime Plugin loaded — transport=ACP (omp acp); probe uses omp --version", + ); + }, + }, + runtime: { + metadata: { + runtimeId: "omp", + name: "OMP Runtime", + version: "0.1.0", + description: "Drives the local `omp acp` Agent Client Protocol server", + }, + factory: async () => new OmpRuntimeAdapter(), + }, + cliProviders: [ + { + providerId: "omp-cli", + displayName: "Oh My Pi (omp)", + binaryName: "omp", + providerType: "cli", + statusRoute: "/providers/omp-cli/status", + authRoute: "/auth/omp-cli", + actions: [ + { actionId: "enable", label: "Enable", actionType: "enable", method: "POST", route: "/auth/omp-cli" }, + { actionId: "disable", label: "Disable", actionType: "disable", method: "POST", route: "/auth/omp-cli" }, + { actionId: "test", label: "Test", actionType: "test", method: "GET", route: "/providers/omp-cli/status" }, + ], + probe: async () => { + const status = await probeOmpBinary(); + return { + available: status.available, + authenticated: status.authenticated, + binaryPath: status.binaryPath, + binaryName: status.binaryName, + version: status.version, + reason: status.reason, + }; + }, + discoverModels: discoverOmpProviderModels, + runtime: { + runtimeId: "omp", + createAdapter: async () => new OmpRuntimeAdapter(), + }, + }, + ], +}); + +export default plugin; +export { probeOmpBinary } from "./probe.js"; +export { discoverOmpProviderModels } from "./provider.js"; +export { OmpRuntimeAdapter } from "./runtime-adapter.js"; +export type { OmpBinaryStatus } from "./types.js"; +export { + buildOmpAcpArgs, + buildOmpAcpRuntimeSettings, + OMP_ACP_ENV_ALLOWLIST, + modelForCli, + normalizeOmpCliModel, + resolveOmpAcpAuthPreferMethods, +} from "./acp-settings.js"; +export { startFusionToolBridge, toolsToMcpToolDefs, FUSION_OMP_TOOL_BRIDGE_URL } from "./tool-bridge.js"; +export { toAcpMcpServers } from "./mcp-forwarding.js"; +export { buildOmpFusionToolRules } from "./runtime-adapter.js"; diff --git a/plugins/fusion-plugin-omp-runtime/src/mcp-forwarding.ts b/plugins/fusion-plugin-omp-runtime/src/mcp-forwarding.ts new file mode 100644 index 0000000000..7d01701870 --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/src/mcp-forwarding.ts @@ -0,0 +1,114 @@ +/* +FNXC:OmpAcp 2026-07-14-00:05: +Convert engine-resolved MCP server definitions (FN-7022 three-transport shape) +into ACP `session/new.mcpServers` entries so omp acp receives the same +operator-approved MCP set as other Fusion AI lanes. Env/header secrets are +already materialized by the engine; this module only reshapes them and never logs +server contents. Shared shape with fusion-plugin-grok-runtime. +*/ + +export type AcpMcpServer = + | { + name: string; + command: string; + args: string[]; + env: { name: string; value: string }[]; + } + | { + type: "http"; + name: string; + url: string; + headers: { name: string; value: string }[]; + } + | { + type: "sse"; + name: string; + url: string; + headers: { name: string; value: string }[]; + }; + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function mapEntries(map: Record | undefined): { name: string; value: string }[] { + if (!map) return []; + return Object.entries(map) + .filter((entry): entry is [string, string] => typeof entry[0] === "string" && typeof entry[1] === "string") + .map(([name, value]) => ({ name, value })); +} + +/** + * Normalize engine `mcpServers` (ResolvedMcpServerDefinition or legacy ACP + * stdio shape) into the ACP wire format omp accepts. + */ +export function toAcpMcpServers(servers: unknown): AcpMcpServer[] { + if (!Array.isArray(servers) || servers.length === 0) return []; + const out: AcpMcpServer[] = []; + + for (const raw of servers) { + const server = asRecord(raw); + if (!server) continue; + const name = typeof server.name === "string" ? server.name.trim() : ""; + if (!name || server.enabled === false) continue; + + // Legacy ACP stdio shape: { name, command, args, env: [{name,value}] } + if (typeof server.command === "string" && server.command.trim() && !("transport" in server) && !("type" in server) && !("url" in server)) { + const envPairs = Array.isArray(server.env) + ? server.env + .map((entry) => asRecord(entry)) + .filter((entry): entry is Record => Boolean(entry)) + .filter((entry) => typeof entry.name === "string" && typeof entry.value === "string") + .map((entry) => ({ name: String(entry.name), value: String(entry.value) })) + : mapEntries(asRecord(server.env) as Record | undefined); + out.push({ + name, + command: server.command.trim(), + args: Array.isArray(server.args) ? server.args.filter((a): a is string => typeof a === "string") : [], + env: envPairs, + }); + continue; + } + + const transport = typeof server.transport === "string" ? server.transport : typeof server.type === "string" ? server.type : "stdio"; + + if (transport === "stdio") { + const command = typeof server.command === "string" ? server.command.trim() : ""; + if (!command) continue; + out.push({ + name, + command, + args: Array.isArray(server.args) ? server.args.filter((a): a is string => typeof a === "string") : [], + env: mapEntries(asRecord(server.env) as Record | undefined), + }); + continue; + } + + if (transport === "http" || transport === "streamable-http") { + const url = typeof server.url === "string" ? server.url.trim() : ""; + if (!url) continue; + out.push({ + type: "http", + name, + url, + headers: mapEntries(asRecord(server.headers) as Record | undefined), + }); + continue; + } + + if (transport === "sse") { + const url = typeof server.url === "string" ? server.url.trim() : ""; + if (!url) continue; + out.push({ + type: "sse", + name, + url, + headers: mapEntries(asRecord(server.headers) as Record | undefined), + }); + } + } + + return out; +} diff --git a/plugins/fusion-plugin-omp-runtime/src/mcp-schema-server.cjs b/plugins/fusion-plugin-omp-runtime/src/mcp-schema-server.cjs new file mode 100644 index 0000000000..3deca32d0b --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/src/mcp-schema-server.cjs @@ -0,0 +1,151 @@ +#!/usr/bin/env node +/* +FNXC:OmpAcp 2026-07-14-00:05: +Executable MCP bridge for Fusion custom tools (fn_*) on the OMP ACP path. +tools/list is served from a schema file; tools/call POSTs to a localhost bridge +owned by OmpRuntimeAdapter so ToolDefinition.execute runs in-process with the +engine's closures. Ported from fusion-plugin-grok-runtime for fn_* parity. +*/ +"use strict"; + +const fs = require("fs"); +const http = require("http"); +const readline = require("readline"); +const { URL } = require("node:url"); + +const schemaPath = process.argv[2]; +const bridgeUrl = process.env.FUSION_OMP_TOOL_BRIDGE_URL; +if (!schemaPath || !bridgeUrl) { + process.stderr.write("fusion-tools-mcp-server: missing schema path or FUSION_OMP_TOOL_BRIDGE_URL\n"); + process.exit(1); +} + +let tools = []; +try { + tools = JSON.parse(fs.readFileSync(schemaPath, "utf-8")); + if (!Array.isArray(tools)) tools = []; +} catch { + process.exit(1); +} + +function write(msg) { + process.stdout.write(JSON.stringify(msg) + "\n"); +} + +function callBridge(toolName, args) { + return new Promise((resolve, reject) => { + const body = JSON.stringify({ name: toolName, arguments: args ?? {} }); + const url = new URL("/tool-call", bridgeUrl); + const req = http.request( + { + hostname: url.hostname, + port: url.port, + path: url.pathname, + method: "POST", + headers: { + "content-type": "application/json", + "content-length": Buffer.byteLength(body), + }, + timeout: 120_000, + }, + (res) => { + let data = ""; + res.on("data", (chunk) => { + data += chunk; + }); + res.on("end", () => { + try { + resolve(JSON.parse(data || "{}")); + } catch (err) { + reject(err); + } + }); + }, + ); + req.on("error", reject); + req.on("timeout", () => { + req.destroy(new Error("tool bridge timeout")); + }); + req.write(body); + req.end(); + }); +} + +const rl = readline.createInterface({ input: process.stdin }); +rl.on("line", (line) => { + let msg; + try { + msg = JSON.parse(line); + } catch { + return; + } + + if (msg.method === "initialize") { + write({ + jsonrpc: "2.0", + id: msg.id, + result: { + protocolVersion: "2024-11-05", + capabilities: { tools: {} }, + serverInfo: { name: "fusion-custom-tools", version: "1.0.0" }, + }, + }); + return; + } + + if (msg.method === "notifications/initialized" || msg.method === "initialized") { + return; + } + + if (msg.method === "tools/list") { + write({ + jsonrpc: "2.0", + id: msg.id, + result: { + tools: tools.map((tool) => ({ + name: tool.name, + description: tool.description ?? "", + inputSchema: tool.inputSchema ?? { type: "object", properties: {} }, + })), + }, + }); + return; + } + + if (msg.method === "tools/call") { + const toolName = msg.params?.name; + const args = msg.params?.arguments ?? {}; + callBridge(toolName, args) + .then((result) => { + write({ + jsonrpc: "2.0", + id: msg.id, + result: { + content: Array.isArray(result.content) + ? result.content + : [{ type: "text", text: typeof result.text === "string" ? result.text : JSON.stringify(result) }], + isError: result.isError === true, + }, + }); + }) + .catch((err) => { + write({ + jsonrpc: "2.0", + id: msg.id, + result: { + content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }], + isError: true, + }, + }); + }); + return; + } + + if (msg.id !== undefined) { + write({ + jsonrpc: "2.0", + id: msg.id, + error: { code: -32601, message: `Method not found: ${msg.method}` }, + }); + } +}); diff --git a/plugins/fusion-plugin-omp-runtime/src/probe.ts b/plugins/fusion-plugin-omp-runtime/src/probe.ts new file mode 100644 index 0000000000..40a8b37ab2 --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/src/probe.ts @@ -0,0 +1,73 @@ +import { runOmpCommand } from "./cli-spawn.js"; +import type { OmpBinaryStatus } from "./types.js"; + +const CANDIDATES = ["omp"] as const; +const MAX_FAILURE_DETAIL_LENGTH = 180; + +function buildCandidates(binaryPath?: string): { candidates: string[]; configuredBinaryPath?: string } { + /* + FNXC:OmpAcp 2026-07-11-23:35: + Manual operator paths must be tried before PATH candidates without deleting + the fallback order. Deduping keeps an `omp` override from probing twice. + */ + const configuredBinaryPath = binaryPath?.trim() || undefined; + const ordered = configuredBinaryPath ? [configuredBinaryPath, ...CANDIDATES] : [...CANDIDATES]; + return { candidates: Array.from(new Set(ordered)), configuredBinaryPath }; +} + +function summarizeFailure(binary: string, stdout: string, stderr: string): string | undefined { + const detail = `${stderr || stdout}`.replace(/\s+/g, " ").trim(); + if (!detail) return undefined; + const truncated = + detail.length > MAX_FAILURE_DETAIL_LENGTH + ? `${detail.slice(0, MAX_FAILURE_DETAIL_LENGTH - 1)}…` + : detail; + return `${binary}: ${truncated}`; +} + +export async function probeOmpBinary(options?: { + timeoutMs?: number; + binaryPath?: string; +}): Promise { + const startedAt = Date.now(); + const timeoutMs = options?.timeoutMs ?? 3000; + const { candidates, configuredBinaryPath } = buildCandidates(options?.binaryPath); + const failureDetails: string[] = []; + + for (const binary of candidates) { + const version = await runOmpCommand(binary, ["--version"], timeoutMs); + const failureDetail = summarizeFailure(binary, version.stdout, version.stderr); + if (failureDetail) failureDetails.push(failureDetail); + const common = { + binaryName: binary, + binaryPath: binary, + configuredBinaryPath, + usingConfiguredBinaryPath: configuredBinaryPath === binary, + diagnostics: failureDetails.length > 0 ? [...failureDetails] : undefined, + probeDurationMs: Date.now() - startedAt, + }; + if (version.code === 0) { + // FNXC:OmpAcp 2026-07-11-23:35: readiness = binary available; omp owns auth (~/.omp). + return { + available: true, + authenticated: true, + ...common, + version: version.stdout.trim() || version.stderr.trim() || undefined, + reason: undefined, + }; + } + } + + const baseReason = configuredBinaryPath + ? `Configured OMP CLI binary '${configuredBinaryPath}' failed; PATH fallback omp also failed` + : "omp not found on PATH"; + return { + available: false, + authenticated: false, + configuredBinaryPath, + usingConfiguredBinaryPath: false, + diagnostics: failureDetails.length > 0 ? failureDetails : undefined, + reason: failureDetails.length > 0 ? `${baseReason} (${failureDetails.join("; ")})` : baseReason, + probeDurationMs: Date.now() - startedAt, + }; +} diff --git a/plugins/fusion-plugin-omp-runtime/src/process-manager.ts b/plugins/fusion-plugin-omp-runtime/src/process-manager.ts new file mode 100644 index 0000000000..d4097c8715 --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/src/process-manager.ts @@ -0,0 +1,70 @@ +import { runOmpCommand } from "./cli-spawn.js"; + +/* +FNXC:OmpAcp 2026-07-11-23:35: +Model discovery for the omp-cli provider card. Prefer a structured list when +available; fall soft to an empty list with a clear reason so the picker stays +usable without inventing model ids. +*/ + +export interface OmpModelDiscoveryResult { + models: string[]; + source: string; + fallbackUsed: boolean; + reason?: string; +} + +/** + * Attempt to list models from the local omp install. + * Tries `omp models` then falls back to empty (CLI default still works via ACP). + */ +export async function discoverOmpModels( + binary: string, + timeoutMs = 8000, +): Promise { + const result = await runOmpCommand(binary, ["models"], timeoutMs); + if (result.code === 0) { + const models = parseModelList(result.stdout || result.stderr); + if (models.length > 0) { + return { models, source: "omp models", fallbackUsed: false }; + } + } + + // Some installs may only expose models via help text; do not invent ids. + return { + models: [], + source: "probe", + fallbackUsed: true, + reason: + result.code === 0 + ? "omp models returned no parseable model ids" + : `omp models failed (code ${result.code ?? "null"})`, + }; +} + +function parseModelList(text: string): string[] { + const lines = text.split(/\r?\n/); + const models: string[] = []; + const seen = new Set(); + + for (const raw of lines) { + const line = raw.trim(); + if (!line || line.startsWith("#") || line.startsWith("┌") || line.startsWith("├") || line.startsWith("└") || line.startsWith("│ model")) { + continue; + } + + // omp models table rows: │ claude-sonnet-4-5 │ 200K │ ... + const tableCell = line.match(/^│\s*([a-zA-Z0-9][\w./+-]*)\s*│/); + // Common shapes: "* model-id (default)", "- model-id", "model-id", "provider/model-id" + const bullet = line.match(/^[-*•]\s+(\S+)/); + const bare = !tableCell && !bullet && !line.includes(" ") ? line : undefined; + const candidate = (tableCell?.[1] ?? bullet?.[1] ?? bare)?.replace(/[(),]/g, "") ?? ""; + if (!candidate || candidate.length < 2) continue; + if (/^(available|default|models?|provider|context|max-out|thinking|images)$/i.test(candidate)) continue; + if (seen.has(candidate)) continue; + seen.add(candidate); + models.push(candidate); + } + + return models; +} diff --git a/plugins/fusion-plugin-omp-runtime/src/provider.ts b/plugins/fusion-plugin-omp-runtime/src/provider.ts new file mode 100644 index 0000000000..bd9c3d5595 --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/src/provider.ts @@ -0,0 +1,25 @@ +import { discoverOmpModels } from "./process-manager.js"; +import { probeOmpBinary } from "./probe.js"; + +function normalizeDiscoveryOptions(options?: unknown): { binaryPath?: string; timeoutMs?: number } { + if (!options || typeof options !== "object") return {}; + const record = options as Record; + return { + binaryPath: typeof record.binaryPath === "string" ? record.binaryPath : undefined, + timeoutMs: typeof record.timeoutMs === "number" ? record.timeoutMs : undefined, + }; +} + +export async function discoverOmpProviderModels(options?: unknown) { + const probe = await probeOmpBinary(normalizeDiscoveryOptions(options)); + if (!probe.available || !probe.binaryName) { + return { models: [], source: "probe", fallbackUsed: true, reason: probe.reason ?? "binary unavailable" }; + } + const result = await discoverOmpModels(probe.binaryPath ?? probe.binaryName); + return { + models: result.models.map((id) => ({ id, label: id })), + source: result.source, + fallbackUsed: result.fallbackUsed, + reason: result.reason, + }; +} diff --git a/plugins/fusion-plugin-omp-runtime/src/runtime-adapter.ts b/plugins/fusion-plugin-omp-runtime/src/runtime-adapter.ts new file mode 100644 index 0000000000..e6e51355c2 --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/src/runtime-adapter.ts @@ -0,0 +1,421 @@ +import { AcpRuntimeAdapter } from "./acp/index.js"; +import { + buildOmpAcpRuntimeSettings, + modelForCli, + normalizeOmpCliModel, +} from "./acp-settings.js"; +import { toAcpMcpServers, type AcpMcpServer } from "./mcp-forwarding.js"; +import { + startFusionToolBridge, + type FusionToolBridge, + type ToolLike, +} from "./tool-bridge.js"; +import type { + AgentRuntime, + AgentRuntimeOptions, + AgentSession, + AgentSessionResult, + OmpSession, +} from "./types.js"; + +/* +FNXC:OmpAcp 2026-07-11-23:35: +Drive Oh My Pi over native ACP (`omp acp`) via vendored AcpRuntimeAdapter under +./acp/. Realtime session/update streaming, tool calls, multi-turn session reuse. +Keep resolve-never-reject on prompt failures so chat/executor always get a +well-formed turn; surface create/prompt failures as visible onText diagnostics +rather than silent empty bubbles (FN-7779 invariant, same as Grok ACP). + +FNXC:OmpAcp 2026-07-14-00:05: +Load Fusion tools + operator MCP into the ACP session for full fn_* parity with +Grok ACP: + - Operator MCP servers → session/new.mcpServers (stdio/http/sse) + - Engine customTools (fn_*) → loopback MCP bridge + fusion-custom-tools server + - System rules describe available Fusion MCP tools so omp prefers them for board ops +*/ + +export type AcpAdapterFactory = (settings: Record) => { + createSession(options: AgentRuntimeOptions): Promise; + promptWithFallback( + session: AgentSession, + prompt: string, + options?: unknown, + ): Promise; + describeModel(session: AgentSession): string; + dispose?(session: AgentSession): Promise; +}; + +export interface OmpRuntimeAdapterOptions { + /** Binary name/path to invoke. Defaults to "omp" (PATH resolution). */ + binary?: string; + /** + * Injectable ACP adapter factory for tests. Production uses + * `AcpRuntimeAdapter` with OMP ACP settings. + */ + createAcpAdapter?: AcpAdapterFactory; +} + +/** Turn-scoped stream accumulators stored on the session for prompt finalization. */ +interface TurnAccum { + text: string; +} + +interface SessionResources { + toolBridge?: FusionToolBridge | null; +} + +const TURN_ACCUM = Symbol("ompTurnAccum"); +const SESSION_RESOURCES = Symbol("ompSessionResources"); + +type SessionWithExtras = OmpSession & { + [TURN_ACCUM]?: TurnAccum; + [SESSION_RESOURCES]?: SessionResources; +}; + +function compactDiagnostic(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function describeCreateFailure(error: unknown): string { + const reason = error instanceof Error ? error.message : String(error ?? "unknown error"); + return compactDiagnostic( + `OMP ACP failed to start: ${reason}. Ensure the \`omp\` binary is installed and authenticated (` + + `\`omp acp\`, credentials under ~/.omp), or set provider API keys in the environment.`, + ); +} + +function describePromptFailure(error: unknown): string { + const reason = error instanceof Error ? error.message : String(error ?? "unknown error"); + return compactDiagnostic(`OMP ACP turn failed: ${reason}`); +} + +function appendMessage(session: OmpSession, role: "user" | "assistant", content: string): void { + const entry = { role, content }; + session.state.messages.push(entry); + if (session.messages !== session.state.messages) { + session.messages.push(entry); + } +} + +function getTurnAccum(session: OmpSession): TurnAccum { + const s = session as SessionWithExtras; + if (!s[TURN_ACCUM]) { + s[TURN_ACCUM] = { text: "" }; + } + return s[TURN_ACCUM]; +} + +function resetTurnAccum(session: OmpSession): void { + getTurnAccum(session).text = ""; +} + +function collectCustomTools(options: AgentRuntimeOptions): ToolLike[] { + const fromCustom = Array.isArray(options.customTools) ? (options.customTools as ToolLike[]) : []; + /* + FNXC:OmpAcp 2026-07-14-00:05: + AgentRuntimeOptions.tools is typed as "coding"|"readonly"|undefined, but some call sites pass + an array of ToolDefinitions. Narrow via Array.isArray on the tools field, then cast the array + value only — never cast the whole options object (TS2352). + */ + const toolsField = (options as { tools?: unknown }).tools; + const maybeToolsArray = Array.isArray(toolsField) ? (toolsField as ToolLike[]) : []; + return [...fromCustom, ...maybeToolsArray]; +} + +/** + * System rules so omp knows Fusion board tools are available via the + * fusion-custom-tools MCP server (not only omp-native tools). + */ +export function buildOmpFusionToolRules(options: { + fusionToolCount?: number; + operatorMcpCount?: number; +}): string { + const parts: string[] = []; + if ((options.fusionToolCount ?? 0) > 0) { + parts.push( + [ + "## Fusion board tools (MCP: fusion-custom-tools)", + `You have access to ${options.fusionToolCount} Fusion in-process tools (names typically start with \`fn_\`) via the MCP server \`fusion-custom-tools\`.`, + "Use them for Fusion board/task/agent/workflow operations instead of inventing shell workarounds.", + "Prefer these tools whenever the user or system prompt asks about tasks, missions, agents, or Fusion state.", + ].join("\n"), + ); + } + if ((options.operatorMcpCount ?? 0) > 0) { + parts.push( + [ + "## Operator MCP servers", + `${options.operatorMcpCount} additional operator-configured MCP server(s) are connected for this session.`, + "Use them when they match the user's request.", + ].join("\n"), + ); + } + return parts.join("\n\n"); +} + +function ensureOmpSessionShape( + session: AgentSession, + model: string, + options: AgentRuntimeOptions, + turnAccum: TurnAccum, + resources: SessionResources, +): OmpSession { + const messages: unknown[] = + Array.isArray((session as OmpSession).messages) ? (session as OmpSession).messages : []; + const existingState = (session as { state?: OmpSession["state"] }).state; + const state: OmpSession["state"] = existingState ?? { messages }; + if (!Array.isArray(state.messages)) { + state.messages = messages; + } + + const omp = session as OmpSession; + omp.model = model; + omp.systemPrompt = omp.systemPrompt ?? options.systemPrompt; + omp.messages = state.messages; + omp.state = state; + omp.lastModelDescription = `omp/${model}`; + omp.callbacks = { + onText: omp.callbacks?.onText ?? options.onText, + onThinking: omp.callbacks?.onThinking ?? options.onThinking, + onToolStart: omp.callbacks?.onToolStart ?? options.onToolStart, + onToolEnd: omp.callbacks?.onToolEnd ?? options.onToolEnd, + }; + + const originalDispose = typeof omp.dispose === "function" ? omp.dispose.bind(omp) : () => undefined; + omp.dispose = () => { + void resources.toolBridge?.dispose(); + originalDispose(); + }; + + (omp as SessionWithExtras)[TURN_ACCUM] = turnAccum; + (omp as SessionWithExtras)[SESSION_RESOURCES] = resources; + return omp; +} + +function createDeadSession( + model: string, + options: AgentRuntimeOptions, + diagnostic: string, + resources?: SessionResources, +): OmpSession { + const messages: unknown[] = []; + return { + model, + systemPrompt: options.systemPrompt, + messages, + state: { messages, errorMessage: diagnostic }, + sessionId: undefined, + lastModelDescription: `omp/${model}`, + callbacks: { + onText: options.onText, + onThinking: options.onThinking, + onToolStart: options.onToolStart, + onToolEnd: options.onToolEnd, + }, + dispose: () => { + void resources?.toolBridge?.dispose(); + }, + }; +} + +export class OmpRuntimeAdapter implements AgentRuntime { + readonly id = "omp"; + readonly name = "OMP Runtime"; + private readonly binary: string; + private readonly createAcpAdapter: AcpAdapterFactory; + /** Per-session ACP adapter so model-specific spawn args stay consistent. */ + private readonly adapters = new WeakMap>(); + + constructor(options?: OmpRuntimeAdapterOptions) { + this.binary = options?.binary ?? "omp"; + /* + FNXC:OmpAcp 2026-07-11-23:35: + AcpRuntimeAdapter returns ACP AgentSession shapes; AcpAdapterFactory is typed + against Omp AgentSessionResult. createSession always runs ensureOmpSessionShape + after ACP create, so the production factory is a deliberate structural bridge + via unknown rather than unifying the two session interfaces here. + */ + this.createAcpAdapter = + options?.createAcpAdapter ?? + ((settings) => new AcpRuntimeAdapter(settings) as unknown as ReturnType); + } + + async createSession( + options: AgentRuntimeOptions = { + cwd: process.cwd(), + systemPrompt: "", + }, + ): Promise { + const model = normalizeOmpCliModel(options.defaultModelId) ?? "omp/default"; + const turnAccum: TurnAccum = { text: "" }; + const resources: SessionResources = {}; + + // ── Operator MCP + Fusion custom tools (fn_*) ───────────────────────── + const operatorMcp = toAcpMcpServers(options.mcpServers); + let toolBridge: FusionToolBridge | null = null; + try { + toolBridge = await startFusionToolBridge(collectCustomTools(options)); + resources.toolBridge = toolBridge; + } catch { + toolBridge = null; + } + + const mcpServers: AcpMcpServer[] = [ + ...operatorMcp, + ...(toolBridge ? [toolBridge.mcpServer] : []), + ]; + + const toolRules = buildOmpFusionToolRules({ + fusionToolCount: toolBridge?.toolCount, + operatorMcpCount: operatorMcp.length, + }); + + const systemPromptParts = [options.systemPrompt?.trim() ?? "", toolRules].filter( + (part) => part.length > 0, + ); + const systemPrompt = systemPromptParts.join("\n\n"); + + /* + FNXC:OmpAcp 2026-07-13-22:50 / 2026-07-14-00:05: + Fusion system/runtime context + tool rules reach omp via session/new._meta + systemPromptOverride (same contract as Grok ACP). + */ + const sessionMeta: Record = { + ...(options.sessionMeta ?? {}), + ...(systemPrompt ? { systemPromptOverride: systemPrompt } : {}), + ...(toolBridge ? { fusionToolCount: toolBridge.toolCount } : {}), + }; + + const sessionOptions: AgentRuntimeOptions = { + ...options, + cwd: options.cwd?.trim() ? options.cwd : process.cwd(), + systemPrompt, + defaultModelId: modelForCli(model) ?? model, + mcpServers, + sessionMeta, + onText: (delta: string) => { + turnAccum.text += delta; + options.onText?.(delta); + }, + onThinking: (delta: string) => { + options.onThinking?.(delta); + }, + onToolStart: (name: string, args?: unknown) => { + options.onToolStart?.(name, args); + }, + onToolEnd: (name: string, isError: boolean, result?: unknown) => { + options.onToolEnd?.(name, isError, result); + }, + }; + + const settings = buildOmpAcpRuntimeSettings({ + binary: this.binary, + model, + }); + const acp = this.createAcpAdapter(settings); + + try { + const result = await acp.createSession(sessionOptions); + /* + FNXC:OmpAcp 2026-07-13-23:10: + Prefer sessionOptions (turnAccum-wrapped callbacks) over the raw engine options when + ACP returns empty callbacks — otherwise assistant text is not accumulated for history. + */ + const session = ensureOmpSessionShape( + result.session, + model, + sessionOptions, + turnAccum, + resources, + ); + this.adapters.set(session, acp); + return { session, sessionFile: result.sessionFile }; + } catch (error) { + const diagnostic = describeCreateFailure(error); + const session = createDeadSession(model, sessionOptions, diagnostic, resources); + session.callbacks.onText?.(diagnostic); + appendMessage(session, "assistant", diagnostic); + return { session, sessionFile: undefined }; + } + } + + async promptWithFallback( + session: AgentSession, + prompt: string, + options?: unknown, + ): Promise { + const ompSession = session as OmpSession; + appendMessage(ompSession, "user", prompt); + resetTurnAccum(ompSession); + + const acp = this.adapters.get(session); + const hasConnection = + acp && "connection" in session && Boolean((session as { connection?: unknown }).connection); + + /* + FNXC:OmpAcp 2026-07-11-23:35: + Dead / disposed sessions have no ACP connection. Follow-up prompts must not + append a user message and return silently — always re-surface a diagnostic + via onText + assistant message so multi-turn chat stays visible. + */ + if (!hasConnection) { + const existing = ompSession.state.errorMessage?.trim(); + const diagnostic = existing + ? `OMP ACP session has no live connection (previous error: ${existing}). Start a new session to retry.` + : "OMP ACP session has no live connection. The `omp acp` process failed to start or was disposed."; + ompSession.state.errorMessage = diagnostic; + ompSession.callbacks.onText?.(diagnostic); + appendMessage(ompSession, "assistant", diagnostic); + return; + } + + try { + const result = await acp!.promptWithFallback(session, prompt, options); + const assistantText = getTurnAccum(ompSession).text; + if (assistantText.length > 0) { + appendMessage(ompSession, "assistant", assistantText); + } else if (result && typeof result === "object" && "stopReason" in result) { + const stopReason = result.stopReason; + if (stopReason && stopReason !== "end_turn" && stopReason !== "EndTurn") { + const diagnostic = `OMP ACP ended with stopReason ${stopReason} and produced no assistant text.`; + ompSession.state.errorMessage = diagnostic; + ompSession.callbacks.onText?.(diagnostic); + appendMessage(ompSession, "assistant", diagnostic); + } + } + return result; + } catch (error) { + const assistantText = getTurnAccum(ompSession).text; + if (assistantText.length === 0) { + const diagnostic = describePromptFailure(error); + ompSession.state.errorMessage = diagnostic; + ompSession.callbacks.onText?.(diagnostic); + appendMessage(ompSession, "assistant", diagnostic); + } else { + appendMessage(ompSession, "assistant", assistantText); + } + return; + } + } + + describeModel(session: AgentSession): string { + const ompSession = session as OmpSession; + return ompSession.lastModelDescription || `omp/${ompSession.model ?? "default"}`; + } + + async dispose(session: AgentSession): Promise { + const resources = (session as SessionWithExtras)[SESSION_RESOURCES]; + try { + await resources?.toolBridge?.dispose(); + } catch { + // best-effort + } + const acp = this.adapters.get(session); + if (acp && typeof acp.dispose === "function") { + await acp.dispose(session); + return; + } + const omp = session as OmpSession; + omp.dispose?.(); + } +} diff --git a/plugins/fusion-plugin-omp-runtime/src/tool-bridge.ts b/plugins/fusion-plugin-omp-runtime/src/tool-bridge.ts new file mode 100644 index 0000000000..90f5c50cf6 --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/src/tool-bridge.ts @@ -0,0 +1,200 @@ +/* +FNXC:OmpAcp 2026-07-14-00:05: +Host Fusion custom tools (fn_*) for the OMP ACP agent. ToolDefinition.execute +closures only work in-process, so OmpRuntimeAdapter starts a loopback HTTP +bridge and pairs it with mcp-schema-server.cjs (stdio MCP) that omp connects to +via session/new.mcpServers. Dispose closes the bridge so no port is left open +after the session ends. Ported from fusion-plugin-grok-runtime for full fn_* parity. +*/ + +import { createServer, type Server } from "node:http"; +import { writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { randomUUID } from "node:crypto"; +import type { AcpMcpServer } from "./mcp-forwarding.js"; + +/** Env var name the stdio MCP server uses to reach this process's tool bridge. */ +export const FUSION_OMP_TOOL_BRIDGE_URL = "FUSION_OMP_TOOL_BRIDGE_URL"; + +const BUILT_IN_TOOL_NAMES = new Set(["read", "write", "edit", "bash", "grep", "find"]); + +export interface ToolLike { + name: string; + description?: string; + parameters?: Record; + execute?: ( + toolCallId: string, + params: unknown, + signal?: AbortSignal, + onUpdate?: unknown, + ctx?: unknown, + ) => Promise | unknown; +} + +export interface McpToolDef { + name: string; + description: string; + inputSchema: Record; +} + +export interface FusionToolBridge { + mcpServer: AcpMcpServer; + dispose: () => Promise; + toolCount: number; +} + +export function toolsToMcpToolDefs(tools: ReadonlyArray | undefined): McpToolDef[] { + if (!Array.isArray(tools)) return []; + return tools + .filter( + (tool) => + tool && + typeof tool.name === "string" && + tool.name.trim().length > 0 && + !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: {} }, + })); +} + +function fusionToolsMcpServerPath(): string { + // Packaged CLI copies this as mcp-schema-server.cjs next to the bundled plugin. + return join(dirname(fileURLToPath(import.meta.url)), "mcp-schema-server.cjs"); +} + +function resultToText(result: unknown): string { + if (result == null) return ""; + if (typeof result === "string") return result; + if (typeof result === "object") { + const obj = result as { content?: unknown; text?: unknown; details?: unknown }; + if (typeof obj.text === "string") return obj.text; + if (Array.isArray(obj.content)) { + return obj.content + .map((block) => { + if ( + block && + typeof block === "object" && + "text" in block && + typeof (block as { text: unknown }).text === "string" + ) { + return (block as { text: string }).text; + } + return JSON.stringify(block); + }) + .join("\n"); + } + } + try { + return JSON.stringify(result); + } catch { + return String(result); + } +} + +/** + * Start a loopback tool bridge and return the ACP mcpServers entry omp should + * connect to for Fusion custom tools. Returns null when there are no tools. + */ +export async function startFusionToolBridge( + tools: ReadonlyArray | undefined, +): Promise { + const defs = toolsToMcpToolDefs(tools); + if (defs.length === 0) return null; + + const byName = new Map(); + for (const tool of tools ?? []) { + if (tool && typeof tool.name === "string" && typeof tool.execute === "function") { + byName.set(tool.name, tool); + } + } + + const schemaPath = join(tmpdir(), `fusion-omp-mcp-schemas-${process.pid}-${randomUUID()}.json`); + writeFileSync(schemaPath, JSON.stringify(defs)); + + const server: Server = createServer(async (req, res) => { + if (req.method !== "POST" || req.url !== "/tool-call") { + res.statusCode = 404; + res.end(JSON.stringify({ isError: true, text: "not found" })); + return; + } + let body = ""; + for await (const chunk of req) body += chunk; + let parsed: { name?: string; arguments?: unknown }; + try { + parsed = JSON.parse(body || "{}") as { name?: string; arguments?: unknown }; + } catch { + res.statusCode = 400; + res.end(JSON.stringify({ isError: true, text: "invalid JSON body" })); + return; + } + const name = typeof parsed.name === "string" ? parsed.name : ""; + const tool = byName.get(name); + if (!tool?.execute) { + res.statusCode = 404; + res.end(JSON.stringify({ isError: true, text: `Unknown Fusion tool: ${name}` })); + return; + } + try { + const result = await tool.execute( + `omp-mcp-${randomUUID()}`, + parsed.arguments ?? {}, + undefined, + undefined, + undefined, + ); + res.statusCode = 200; + res.setHeader("content-type", "application/json"); + res.end( + JSON.stringify({ + isError: false, + content: [{ type: "text", text: resultToText(result) }], + }), + ); + } catch (err) { + res.statusCode = 200; + res.setHeader("content-type", "application/json"); + res.end( + JSON.stringify({ + isError: true, + content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }], + }), + ); + } + }); + + const address = await new Promise<{ port: number }>((resolve, reject) => { + server.once("error", reject); + // Bind loopback only — never expose Fusion tools on a public interface. + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + if (!addr || typeof addr === "string") { + reject(new Error("tool bridge failed to bind")); + return; + } + resolve({ port: addr.port }); + }); + }); + + const bridgeUrl = `http://127.0.0.1:${address.port}`; + const serverPath = fusionToolsMcpServerPath(); + + return { + toolCount: defs.length, + mcpServer: { + name: "fusion-custom-tools", + command: process.execPath, + args: [serverPath, schemaPath], + env: [{ name: FUSION_OMP_TOOL_BRIDGE_URL, value: bridgeUrl }], + }, + dispose: async () => { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + }, + }; +} diff --git a/plugins/fusion-plugin-omp-runtime/src/types.ts b/plugins/fusion-plugin-omp-runtime/src/types.ts new file mode 100644 index 0000000000..a117a68ceb --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/src/types.ts @@ -0,0 +1,120 @@ +/* +FNXC:OmpAcp 2026-07-11-23:35: +OMP runtime drives Oh My Pi (`omp acp`) over the Agent Client Protocol. Session +state mirrors chat/executor expectations (top-level messages + optional +state.errorMessage) while the live ACP connection lives on composed AcpSession +fields (connection, dispose). Auth is owned by the operator's local `omp` +install under ~/.omp — Fusion does not inject provider keys. +*/ + +/** Narrow permission gate view (structural copy; no @fusion/engine import). */ +export type GateDisposition = "allow" | "block" | "require-approval"; + +export interface PermissionGate { + permissionPolicy?: { + rules?: Record; + }; + createApprovalRequest?: ( + decision: unknown, + args: Record, + ) => Promise | unknown; + findApprovalByDedupeKey?: ( + dedupeKey: string, + ) => Promise<{ id: string; status: string } | null> | { id: string; status: string } | null; + pauseForApproval?: (info: { + approvalRequestId: string; + decision: unknown; + }) => Promise | void; + markApprovalCompleted?: (approvalRequestId: string) => Promise | void; +} + +import type { AcpMcpServer } from "./mcp-forwarding.js"; + +/** Re-export multi-transport MCP shape used on ACP session/new. */ +export type { AcpMcpServer } from "./mcp-forwarding.js"; + +export interface OmpCallbacks { + /** Streams assistant text deltas from ACP `agent_message_chunk` updates. */ + onText?: (text: string) => void; + /** Streams reasoning from ACP `agent_thought_chunk` updates. */ + onThinking?: (text: string) => void; + /** ACP `tool_call` / start of a tool invocation. */ + onToolStart?: (toolName: string, args?: unknown) => void; + /** ACP `tool_call_update` terminal status. */ + onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void; +} + +export interface OmpSession { + model: string; + systemPrompt?: string; + messages: unknown[]; + state: { errorMessage?: string; messages: unknown[] }; + sessionId?: string; + lastModelDescription: string; + callbacks: OmpCallbacks; + /** Live ACP connection when createSession succeeded (composed AcpSession). */ + connection?: unknown; + resetTurn?: () => void; + dispose?: () => void; +} + +export type AgentSession = OmpSession; + +export interface AgentRuntimeOptions { + cwd?: string; + systemPrompt?: string; + tools?: "coding" | "readonly"; + defaultModelId?: string; + onText?: (text: string) => void; + onThinking?: (text: string) => void; + onToolStart?: (toolName: string, args?: unknown) => void; + onToolEnd?: (toolName: string, isError: boolean, result?: unknown) => void; + signal?: AbortSignal; + actionGateContext?: PermissionGate; + mcpServers?: AcpMcpServer[] | unknown[]; + customTools?: unknown[]; + skills?: string[]; + skillSelection?: { requestedSkillNames?: string[] }; + additionalSkillPaths?: string[]; + sessionMeta?: Record; +} + +export interface AgentSessionResult { + session: AgentSession; + sessionFile?: string; +} + +export interface AgentPromptResult { + stopReason?: string; +} + +export interface AgentRuntime { + id: string; + name: string; + createSession(options: AgentRuntimeOptions): Promise; + promptWithFallback( + session: AgentSession, + prompt: string, + options?: unknown, + ): Promise; + describeModel(session: AgentSession): string; + dispose?(session: AgentSession): Promise; +} + +export interface OmpBinaryStatus { + available: boolean; + /** + * FNXC:OmpAcp 2026-07-11-23:35: + * Means "OMP CLI runtime ready" (the `omp` binary is available). Auth is + * owned by omp under ~/.omp; Fusion does not require a Fusion-visible API key. + */ + authenticated?: boolean; + binaryPath?: string; + binaryName?: string; + configuredBinaryPath?: string; + usingConfiguredBinaryPath?: boolean; + diagnostics?: string[]; + version?: string; + reason?: string; + probeDurationMs: number; +} diff --git a/plugins/fusion-plugin-omp-runtime/tsconfig.json b/plugins/fusion-plugin-omp-runtime/tsconfig.json new file mode 100644 index 0000000000..ac4dbc8f69 --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": true + }, + "include": ["src/**/*.ts"] +} diff --git a/plugins/fusion-plugin-omp-runtime/vitest.config.ts b/plugins/fusion-plugin-omp-runtime/vitest.config.ts new file mode 100644 index 0000000000..ccd5ae0ff0 --- /dev/null +++ b/plugins/fusion-plugin-omp-runtime/vitest.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "vitest/config"; +import { fileURLToPath } from "node:url"; +import { computeMaxWorkers } from "../../packages/core/src/__test-utils__/vitest-workers"; + +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"], + environment: "node", + 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, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8392b6604e..ee4585f566 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -267,6 +267,9 @@ importers: '@fusion-plugin-examples/hermes-runtime': specifier: workspace:* version: link:../../plugins/fusion-plugin-hermes-runtime + '@fusion-plugin-examples/omp-runtime': + specifier: workspace:* + version: link:../../plugins/fusion-plugin-omp-runtime '@fusion-plugin-examples/openclaw-runtime': specifier: workspace:* version: link:../../plugins/fusion-plugin-openclaw-runtime @@ -1072,6 +1075,34 @@ importers: specifier: ^4.1.0 version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.1)(jsdom@29.0.1)(vite@6.4.1(@types/node@25.5.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + plugins/fusion-plugin-omp-runtime: + dependencies: + '@agentclientprotocol/sdk': + specifier: 0.24.0 + version: 0.24.0(zod@4.3.6) + '@earendil-works/pi-ai': + specifier: '*' + version: 0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + '@earendil-works/pi-coding-agent': + specifier: '*' + version: 0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + '@fusion/core': + specifier: workspace:* + version: link:../../packages/core + '@fusion/plugin-sdk': + specifier: workspace:* + version: link:../../packages/plugin-sdk + devDependencies: + '@types/node': + specifier: ^25.5.2 + version: 25.5.2 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^4.1.0 + version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.1)(jsdom@29.0.1)(vite@6.4.1(@types/node@25.5.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + plugins/fusion-plugin-openclaw-runtime: dependencies: '@fusion/plugin-sdk': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index edb6c47b3a..2eb04699dc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -22,6 +22,7 @@ packages: - "plugins/fusion-plugin-acp-runtime" - "plugins/fusion-plugin-cursor-runtime" - "plugins/fusion-plugin-grok-runtime" + - "plugins/fusion-plugin-omp-runtime" - "plugins/fusion-plugin-agent-browser" - "plugins/fusion-plugin-whatsapp-chat" - "plugins/fusion-plugin-roadmap"