feat: add Oh My Pi (omp) ACP runtime plugin (#2083)

## 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

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
gsxdsm
2026-07-14 08:18:52 -07:00
committed by GitHub
parent d7e072a03c
commit b563b12662
63 changed files with 5736 additions and 6 deletions

78
docs/omp-acp-contract.md Normal file
View File

@@ -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 <id> 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.

View File

@@ -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;

View File

@@ -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));

View File

@@ -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.
*/

View File

@@ -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");
});
});

View File

@@ -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",

View File

@@ -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,

View File

@@ -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

View File

@@ -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<GrokCliStatus> {
return api<GrokCliStatus>("/providers/grok-cli/status");
}
export function fetchOmpCliStatus(): Promise<OmpCliStatus> {
return api<OmpCliStatus>("/providers/omp-cli/status");
}
/** Probe llama.cpp server + setting + extension state. */
export function fetchLlamaCppStatus(): Promise<LlamaCppStatus> {
return api<LlamaCppStatus>("/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,

View File

@@ -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);
}
}

View File

@@ -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<OmpCliStatus | null>(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 ? (
<div className="omp-cli-binary-path-control">
<label className="omp-cli-binary-path-label" htmlFor="omp-cli-binary-path">
{t("setup.ompCli.binaryPathLabel", "OMP CLI binary path")}
</label>
<div className="omp-cli-binary-path-row">
<input
id="omp-cli-binary-path"
className="omp-cli-binary-path-input"
type="text"
value={binaryPathInput}
onChange={(event) => handleBinaryPathChange(event.target.value)}
placeholder={t("setup.ompCli.binaryPathPlaceholder", "/usr/local/bin/omp")}
disabled={busy !== null}
/>
<button type="button" className="btn btn-sm" onClick={() => void handleSaveBinaryPath()} disabled={busy !== null || !binaryPathChanged}>
{busy === "saving-path" ? t("setup.ompCli.savingPath", "Saving…") : t("setup.ompCli.saveAndTestPath", "Save & Test")}
</button>
</div>
<small className="settings-muted">{t("setup.ompCli.binaryPathHelp", "Leave blank to use PATH auto-detection (`omp`).")}</small>
{pathMessage ? <small className={pathMessage.tone === "error" ? "form-error" : "text-muted"}>{pathMessage.text}</small> : null}
</div>
) : null;
const actions = (
<>
<button type="button" className="btn btn-sm" onClick={() => {
setBusy("testing");
void refresh().finally(() => {
if (mountedRef.current) setBusy(null);
});
}} disabled={busy !== null}>
{busy === "testing" ? <><Loader2 size={12} className="animate-spin" /> {t("setup.ompCli.testing", "Testing…")}</> : t("setup.ompCli.test", "Test")}
</button>
{currentlyEnabled ? (
<button type="button" className="btn btn-sm" onClick={() => void handleToggle(false)} disabled={busy !== null}>
{busy === "disabling" ? t("setup.ompCli.disabling", "Disabling…") : t("setup.ompCli.disable", "Disable")}
</button>
) : (
<button type="button" className="btn btn-primary btn-sm" onClick={() => void handleToggle(true)} disabled={busy !== null || !binaryAvailable}>
{busy === "enabling" ? t("setup.ompCli.enabling", "Enabling…") : t("setup.ompCli.enable", "Enable")}
</button>
)}
</>
);
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 (
<div className={`omp-cli-provider-card auth-provider-card auth-provider-card--cli${authenticated ? " auth-provider-card--authenticated" : ""}`} data-testid="omp-cli-provider-card">
<div className="auth-provider-header">
<div className="auth-provider-info">
<ProviderIcon provider="omp-cli" size="sm" />
<strong>{t("setup.ompCli.providerName", "Oh My Pi — via omp ACP")}</strong>
<span className={`auth-status-badge ${currentlyEnabled ? "authenticated" : "not-authenticated"}`}>{currentlyEnabled ? t("setup.ompCli.active", "✓ Active") : t("setup.ompCli.notConnected", "✗ Not connected")}</span>
</div>
<div className="auth-provider-cli-actions">{actions}</div>
</div>
<div className="omp-cli-provider-card__body" data-testid="omp-cli-provider-card-body">
<small className="settings-muted">{statusText}</small>
{statusMessage ? (
<small className={statusMessage.tone === "error" ? "form-error" : "text-muted"}>{statusMessage.text}</small>
) : null}
<small className="settings-muted omp-cli-provider-card__hint">
{t("setup.ompCli.authHint", "Credentials live under ~/.omp (agent auth). Fusion does not store omp API keys.")}
</small>
{binaryPathControl}
</div>
</div>
);
}
return (
<div className={`omp-cli-provider-card onboarding-provider-card${authenticated ? " onboarding-provider-card--connected" : ""}`} data-testid="omp-cli-provider-card">
<div className="onboarding-provider-card__icon">
<ProviderIcon provider="omp-cli" size="md" />
</div>
<div className="onboarding-provider-card__body">
<strong className="onboarding-provider-card__name">{t("setup.ompCli.providerName", "Oh My Pi — via omp ACP")}</strong>
<span className="onboarding-provider-card__description">{t("setup.ompCli.description", "Drive sessions through your local omp ACP server (`omp acp`).")}</span>
<small className="settings-muted">{statusText}</small>
</div>
<div className="onboarding-provider-card__actions">{actions}</div>
</div>
);
}

View File

@@ -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 (<GrokCliProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={handleCliProviderToggled}/>);
}
if (provider.id === "omp-cli") {
return (<OmpCliProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={handleCliProviderToggled}/>);
}
return (<LlamaCppProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={handleCliProviderToggled}/>);
};
const showAuthenticatedGroup = authenticatedProviders.length > 0;

View File

@@ -472,6 +472,8 @@ const NOT_SURFACED_ALLOWLIST: Record<string, string> = {
// 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",

View File

@@ -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:*",

View File

@@ -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);
});
});

View File

@@ -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<string>();
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<string, CacheEntry>();
const inFlight = new Map<string, Promise<OmpPickerModel[]>>();
export function __resetOmpPickerModelsCacheForTests(): void {
cache.clear();
inFlight.clear();
}
export interface GetOmpPickerModelsOptions {
binaryPath?: string;
ttlMs?: number;
now?: () => number;
}
export async function getOmpPickerModels(
opts?: GetOmpPickerModelsOptions,
): Promise<OmpPickerModel[]> {
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<OmpPickerModel[]> => {
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);
}
}

View File

@@ -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",
]);

View File

@@ -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<string | undefined> {
if (!store) return undefined;
const globalSettings = await store.getGlobalSettingsStore().getSettings();
return normalizeOmpCliBinaryPath((globalSettings as Record<string, unknown>).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<string, unknown>).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<string, unknown>).useOmpCli === true;
const currentBinaryPath = normalizeOmpCliBinaryPath((currentSettings as Record<string, unknown>).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<string, unknown> = {};
if (hasEnabledPatch) {
patch.useOmpCli = enabled;
}
if (hasBinaryPathPatch) {
patch.ompCliBinaryPath = nextBinaryPath ?? null;
}
const settings = await store.updateGlobalSettings(patch);
invalidateAllGlobalSettingsCaches();
res.json({
enabled: (settings as Record<string, unknown>).useOmpCli === true,
binaryPath: normalizeOmpCliBinaryPath((settings as Record<string, unknown>).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<string, unknown>).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) {

View File

@@ -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<string, unknown>).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<string, unknown>).useOmpCli === true;
const rawOmpCliBinaryPath = (globalSettings as Record<string, unknown>).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

View File

@@ -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<OmpBinaryStatus> {
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.
*

View File

@@ -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",

View File

@@ -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", () => {

View File

@@ -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() ?? "";

View File

@@ -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`)

View File

@@ -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 <id> 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.

View File

@@ -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)"
}

View File

@@ -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"
}
}

View File

@@ -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,
}),
);
});
});

View File

@@ -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");
});
});

View File

@@ -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([]);
});
});

View File

@@ -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<string, unknown>;
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);
});

View File

@@ -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);
}
});
});

View File

@@ -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>): 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<AgentSessionResult> => ({
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\//);
});
});

View File

@@ -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");
});
});

View File

@@ -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 <id> 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<string, unknown> {
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,
},
};
}

View File

@@ -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.

View File

@@ -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<string, unknown>;
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<string, unknown>): 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<string, unknown>;
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<string, unknown>)
: { 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<string, unknown>): 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"] };
}

View File

@@ -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<string, unknown>)
: {},
});
}
/**
* 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<string, unknown>;
},
): 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<RequestPermissionResponse> {
// 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));
}

View File

@@ -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<string, TrackedToolCall>();
// 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<SessionUpdate, { sessionUpdate: "tool_call" }>): 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<SessionUpdate, { sessionUpdate: "tool_call_update" }>,
): 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 };
}

View File

@@ -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<ReadTextFileResponse>;
writeTextFile?: (params: WriteTextFileRequest) => Promise<WriteTextFileResponse>;
}
/**
* 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<ReadTextFileResponse> => {
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<WriteTextFileResponse> => {
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;
}

View File

@@ -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";

View File

@@ -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<string> {
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<FileHandle> {
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/<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;
}
}

View File

@@ -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<ChildProcess>();
/**
* 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);
}

View File

@@ -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;
}

View File

@@ -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<RequestPermissionResponse> {
// 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<RequestPermissionResponse>((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<string, unknown>;
require?: boolean;
};
}
function withTimeout<T>(promise: Promise<T>, ms: number, onTimeout: () => Error): Promise<T> {
return new Promise<T>((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<AcpConnection> {
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<never>((_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<Uint8Array>,
Readable.toWeb(child.stdout!) as unknown as ReadableStream<Uint8Array>,
);
const handler = opts.clientHandler ?? createDefaultClientHandler();
const conn = new ClientSideConnection((_agent: Agent) => handler, stream);
let initResult: Awaited<ReturnType<ClientSideConnection["initialize"]>>;
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<AcpConnection, "conn" | "authMethods">,
opts: {
preferMethods?: string[];
methodId?: string;
meta?: Record<string, unknown>;
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<string, unknown>;
},
): Promise<NewAcpSessionResult> {
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<StopReason> {
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<void> {
// `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<void>((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<NewAcpSessionResult> {
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 });
}

View File

@@ -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<string, unknown>) {
this.settings = resolveCliSettings(settings);
}
async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> {
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<string, unknown> }).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<void> {
// 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();
}
}
}

View File

@@ -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 [ ... <final byte>
// 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);
}

View File

@@ -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<ToolKind, string> = {
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<string, unknown> {
if (rawInput === null || typeof rawInput !== "object" || Array.isArray(rawInput)) {
return {};
}
return rawInput as Record<string, unknown>;
}

View File

@@ -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<string, GateDisposition>;
};
/** Register an approval request; returns the created record (with an `id`). */
createApprovalRequest?: (
decision: unknown,
args: Record<string, unknown>,
) => Promise<unknown> | 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> | void;
/** Mark an approval request finalized after the decision is consumed. */
markApprovalCompleted?: (approvalRequestId: string) => Promise<void> | 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<string, unknown>;
}
/** 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<AgentSessionResult>;
promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise<void | AgentPromptResult>;
describeModel(session: AgentSession): string;
dispose?(session: AgentSession): Promise<void>;
}

View File

@@ -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 });
});
});
}

View File

@@ -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";

View File

@@ -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<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function mapEntries(map: Record<string, string> | 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<string, unknown> => 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<string, string> | 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<string, string> | 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<string, string> | 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<string, string> | undefined),
});
}
}
return out;
}

View File

@@ -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}` },
});
}
});

View File

@@ -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<OmpBinaryStatus> {
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,
};
}

View File

@@ -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<OmpModelDiscoveryResult> {
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<string>();
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;
}

View File

@@ -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<string, unknown>;
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,
};
}

View File

@@ -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<string, unknown>) => {
createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult>;
promptWithFallback(
session: AgentSession,
prompt: string,
options?: unknown,
): Promise<void | { stopReason?: string }>;
describeModel(session: AgentSession): string;
dispose?(session: AgentSession): Promise<void>;
};
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<object, ReturnType<AcpAdapterFactory>>();
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<AcpAdapterFactory>);
}
async createSession(
options: AgentRuntimeOptions = {
cwd: process.cwd(),
systemPrompt: "",
},
): Promise<AgentSessionResult> {
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<string, unknown> = {
...(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<void | { stopReason?: string }> {
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<void> {
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?.();
}
}

View File

@@ -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<string, unknown>;
execute?: (
toolCallId: string,
params: unknown,
signal?: AbortSignal,
onUpdate?: unknown,
ctx?: unknown,
) => Promise<unknown> | unknown;
}
export interface McpToolDef {
name: string;
description: string;
inputSchema: Record<string, unknown>;
}
export interface FusionToolBridge {
mcpServer: AcpMcpServer;
dispose: () => Promise<void>;
toolCount: number;
}
export function toolsToMcpToolDefs(tools: ReadonlyArray<ToolLike> | 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<ToolLike> | undefined,
): Promise<FusionToolBridge | null> {
const defs = toolsToMcpToolDefs(tools);
if (defs.length === 0) return null;
const byName = new Map<string, ToolLike>();
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<void>((resolve) => {
server.close(() => resolve());
});
},
};
}

View File

@@ -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<string, GateDisposition>;
};
createApprovalRequest?: (
decision: unknown,
args: Record<string, unknown>,
) => Promise<unknown> | unknown;
findApprovalByDedupeKey?: (
dedupeKey: string,
) => Promise<{ id: string; status: string } | null> | { id: string; status: string } | null;
pauseForApproval?: (info: {
approvalRequestId: string;
decision: unknown;
}) => Promise<void> | void;
markApprovalCompleted?: (approvalRequestId: string) => Promise<void> | 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<string, unknown>;
}
export interface AgentSessionResult {
session: AgentSession;
sessionFile?: string;
}
export interface AgentPromptResult {
stopReason?: string;
}
export interface AgentRuntime {
id: string;
name: string;
createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult>;
promptWithFallback(
session: AgentSession,
prompt: string,
options?: unknown,
): Promise<void | AgentPromptResult>;
describeModel(session: AgentSession): string;
dispose?(session: AgentSession): Promise<void>;
}
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;
}

View File

@@ -0,0 +1,10 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"declarationMap": true
},
"include": ["src/**/*.ts"]
}

View File

@@ -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,
},
});

31
pnpm-lock.yaml generated
View File

@@ -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':

View File

@@ -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"