Files
fusion/plugins/fusion-plugin-acp-runtime/src/cli-spawn.ts
gsxdsm a9815fb1ff FN-6457: add ACP ask runner and bundled Claude bridge setup
Route ACP-backed planning and validation through a read-only ask-once runner with a pinned Claude bridge foundation.

- Add askAcpOnce for single-turn ACP sessions with timeout handling, JSON recovery, clean stop validation, and disposal.
- Refactor validation seams to use ACP runtime prompts and require structured pass verdicts.
- Resolve the Claude ACP bridge from the plugin bundle and add setup checks for identity, environment, probing, and auth readiness.
- Document the ACP Route B plan and update tests for validator, session, runtime, and plugin setup behavior.

Files changed:
 CONCEPTS.md                                        |   6 +
 docs/acp-contract.md                               |  36 ++
 .../2026-06-14-001-feat-claude-acp-runtime-plan.md | 465 +++++++++++++++++++++
 .../engine/src/__tests__/cli-agent-ask.test.ts     | 104 +++++
 .../src/__tests__/cli-agent-validator.test.ts      | 137 +++---
 .../src/__tests__/interactive-ai-session.test.ts   |  96 +++--
 packages/engine/src/agent-runtime.ts               |   6 +-
 packages/engine/src/cli-agent-ask.ts               | 120 ++++++
 packages/engine/src/cli-agent-validator.ts         |  65 ++-
 .../cli-agent/__tests__/one-shot-session.test.ts   |  16 +-
 packages/engine/src/cli-agent/one-shot-session.ts  |  17 +-
 packages/engine/src/index.ts                       |   8 +-
 packages/engine/src/interactive-ai-session.ts      |  33 +-
 plugins/fusion-plugin-acp-runtime/AGENTS.md        |  14 +
 plugins/fusion-plugin-acp-runtime/CHANGELOG.md     |   6 +
 plugins/fusion-plugin-acp-runtime/README.md        |  13 +-
 plugins/fusion-plugin-acp-runtime/package.json     |   3 +-
 .../src/__tests__/index.test.ts                    |  51 ++-
 .../src/__tests__/process-manager.test.ts          |  32 +-
 .../src/__tests__/runtime-adapter.test.ts          |   4 +-
 .../src/__tests__/setup.test.ts                    |  71 ++++
 plugins/fusion-plugin-acp-runtime/src/cli-spawn.ts |  95 ++++-
 plugins/fusion-plugin-acp-runtime/src/index.ts     |  16 +-
 .../src/process-manager.ts                         |  26 +-
 .../src/runtime-adapter.ts                         |  11 +-
 plugins/fusion-plugin-acp-runtime/src/setup.ts     | 104 +++++
 plugins/fusion-plugin-acp-runtime/src/types.ts     |   6 +-
 pnpm-lock.yaml                                     | 139 ++++--
 28 files changed, 1502 insertions(+), 198 deletions(-)

Fusion-Task-Id: FN-6457
Fusion-Task-Lineage: a3364ed7-cb28-4a2b-b898-6ccd0d95fb92
2026-06-15 02:30:41 -07:00

153 lines
5.6 KiB
TypeScript

// 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;
}
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;
}
}
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);
return {
binaryPath,
args,
model,
fsRead,
fsWrite,
envAllowList,
requiredEnv: [],
allowUnrestricted,
binaryResolution,
};
}
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"] };
}