import path from "node:path"; import { fileURLToPath } from "node:url"; import type { FasedAgentPluginConfigSchema } from "fased/plugin-sdk"; export const ACPX_PERMISSION_MODES = ["approve-all", "approve-reads", "deny-all"] as const; export type AcpxPermissionMode = (typeof ACPX_PERMISSION_MODES)[number]; export const ACPX_NON_INTERACTIVE_POLICIES = ["deny", "fail"] as const; export type AcpxNonInteractivePermissionPolicy = (typeof ACPX_NON_INTERACTIVE_POLICIES)[number]; export const ACPX_MCP_BRIDGE_MODES = [ "status-only", "read-only-tools", "operator-approved-mutating-tools", ] as const; export type AcpxMcpBridgeMode = (typeof ACPX_MCP_BRIDGE_MODES)[number]; export const ACPX_PINNED_VERSION = "0.1.13"; const ACPX_BIN_NAME = process.platform === "win32" ? "acpx.cmd" : "acpx"; export const ACPX_PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); export const ACPX_BUNDLED_BIN = path.join(ACPX_PLUGIN_ROOT, "node_modules", ".bin", ACPX_BIN_NAME); export const ACPX_LOCAL_INSTALL_COMMAND = `npm install --omit=dev --no-save acpx@${ACPX_PINNED_VERSION}`; export type AcpxMcpBridgeConfig = { enabled?: boolean; mode?: AcpxMcpBridgeMode; allowTools?: string[]; denyTools?: string[]; }; export type ResolvedAcpxMcpBridgeConfig = { enabled: boolean; mode: AcpxMcpBridgeMode; allowTools: string[]; denyTools: string[]; }; export type AcpxPluginConfig = { cwd?: string; permissionMode?: AcpxPermissionMode; nonInteractivePermissions?: AcpxNonInteractivePermissionPolicy; timeoutSeconds?: number; queueOwnerTtlSeconds?: number; mcpBridge?: AcpxMcpBridgeConfig; }; export type ResolvedAcpxPluginConfig = { command: string; cwd: string; permissionMode: AcpxPermissionMode; nonInteractivePermissions: AcpxNonInteractivePermissionPolicy; timeoutSeconds?: number; queueOwnerTtlSeconds: number; mcpBridge: ResolvedAcpxMcpBridgeConfig; }; const DEFAULT_PERMISSION_MODE: AcpxPermissionMode = "approve-reads"; const DEFAULT_NON_INTERACTIVE_POLICY: AcpxNonInteractivePermissionPolicy = "fail"; const DEFAULT_QUEUE_OWNER_TTL_SECONDS = 0.1; const DEFAULT_MCP_BRIDGE_MODE: AcpxMcpBridgeMode = "status-only"; type ParseResult = | { ok: true; value: AcpxPluginConfig | undefined } | { ok: false; message: string }; type ToolListParseResult = { ok: true; value?: string[] } | { ok: false; message: string }; type McpBridgeParseResult = | { ok: true; value?: AcpxMcpBridgeConfig } | { ok: false; message: string }; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function isPermissionMode(value: string): value is AcpxPermissionMode { return ACPX_PERMISSION_MODES.includes(value as AcpxPermissionMode); } function isNonInteractivePermissionPolicy( value: string, ): value is AcpxNonInteractivePermissionPolicy { return ACPX_NON_INTERACTIVE_POLICIES.includes(value as AcpxNonInteractivePermissionPolicy); } function isMcpBridgeMode(value: string): value is AcpxMcpBridgeMode { return ACPX_MCP_BRIDGE_MODES.includes(value as AcpxMcpBridgeMode); } function parseToolList(value: unknown, label: string): ToolListParseResult { if (value === undefined) { return { ok: true, value: undefined }; } if (!Array.isArray(value)) { return { ok: false, message: `${label} must be an array of non-empty strings` }; } const normalized: string[] = []; for (const entry of value) { if (typeof entry !== "string" || entry.trim() === "") { return { ok: false, message: `${label} must be an array of non-empty strings` }; } normalized.push(entry.trim()); } return { ok: true, value: [...new Set(normalized)] }; } function parseMcpBridgeConfig(value: unknown): McpBridgeParseResult { if (value === undefined) { return { ok: true, value: undefined }; } if (!isRecord(value)) { return { ok: false, message: "mcpBridge must be an object" }; } const allowedKeys = new Set(["enabled", "mode", "allowTools", "denyTools"]); for (const key of Object.keys(value)) { if (!allowedKeys.has(key)) { return { ok: false, message: `unknown mcpBridge config key: ${key}` }; } } const enabled = value.enabled; if (enabled !== undefined && typeof enabled !== "boolean") { return { ok: false, message: "mcpBridge.enabled must be a boolean" }; } const mode = value.mode; if (mode !== undefined && (typeof mode !== "string" || !isMcpBridgeMode(mode))) { return { ok: false, message: `mcpBridge.mode must be one of: ${ACPX_MCP_BRIDGE_MODES.join(", ")}`, }; } const allowTools = parseToolList(value.allowTools, "mcpBridge.allowTools"); if (!allowTools.ok) { return allowTools; } const denyTools = parseToolList(value.denyTools, "mcpBridge.denyTools"); if (!denyTools.ok) { return denyTools; } return { ok: true, value: { enabled: typeof enabled === "boolean" ? enabled : undefined, mode: typeof mode === "string" && isMcpBridgeMode(mode) ? mode : undefined, allowTools: allowTools.value, denyTools: denyTools.value, }, }; } function parseAcpxPluginConfig(value: unknown): ParseResult { if (value === undefined) { return { ok: true, value: undefined }; } if (!isRecord(value)) { return { ok: false, message: "expected config object" }; } const allowedKeys = new Set([ "cwd", "permissionMode", "nonInteractivePermissions", "timeoutSeconds", "queueOwnerTtlSeconds", "mcpBridge", ]); for (const key of Object.keys(value)) { if (!allowedKeys.has(key)) { return { ok: false, message: `unknown config key: ${key}` }; } } const cwd = value.cwd; if (cwd !== undefined && (typeof cwd !== "string" || cwd.trim() === "")) { return { ok: false, message: "cwd must be a non-empty string" }; } const permissionMode = value.permissionMode; if ( permissionMode !== undefined && (typeof permissionMode !== "string" || !isPermissionMode(permissionMode)) ) { return { ok: false, message: `permissionMode must be one of: ${ACPX_PERMISSION_MODES.join(", ")}`, }; } const nonInteractivePermissions = value.nonInteractivePermissions; if ( nonInteractivePermissions !== undefined && (typeof nonInteractivePermissions !== "string" || !isNonInteractivePermissionPolicy(nonInteractivePermissions)) ) { return { ok: false, message: `nonInteractivePermissions must be one of: ${ACPX_NON_INTERACTIVE_POLICIES.join(", ")}`, }; } const timeoutSeconds = value.timeoutSeconds; if ( timeoutSeconds !== undefined && (typeof timeoutSeconds !== "number" || !Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) ) { return { ok: false, message: "timeoutSeconds must be a positive number" }; } const queueOwnerTtlSeconds = value.queueOwnerTtlSeconds; if ( queueOwnerTtlSeconds !== undefined && (typeof queueOwnerTtlSeconds !== "number" || !Number.isFinite(queueOwnerTtlSeconds) || queueOwnerTtlSeconds < 0) ) { return { ok: false, message: "queueOwnerTtlSeconds must be a non-negative number" }; } const mcpBridge = parseMcpBridgeConfig(value.mcpBridge); if (!mcpBridge.ok) { return mcpBridge; } return { ok: true, value: { cwd: typeof cwd === "string" ? cwd.trim() : undefined, permissionMode: typeof permissionMode === "string" ? permissionMode : undefined, nonInteractivePermissions: typeof nonInteractivePermissions === "string" ? nonInteractivePermissions : undefined, timeoutSeconds: typeof timeoutSeconds === "number" ? timeoutSeconds : undefined, queueOwnerTtlSeconds: typeof queueOwnerTtlSeconds === "number" ? queueOwnerTtlSeconds : undefined, mcpBridge: mcpBridge.value, }, }; } export function createAcpxPluginConfigSchema(): FasedAgentPluginConfigSchema { return { safeParse(value: unknown): | { success: true; data?: unknown } | { success: false; error: { issues: Array<{ path: Array; message: string }> }; } { const parsed = parseAcpxPluginConfig(value); if (parsed.ok) { return { success: true, data: parsed.value }; } return { success: false, error: { issues: [{ path: [], message: parsed.message }], }, }; }, jsonSchema: { type: "object", additionalProperties: false, properties: { cwd: { type: "string" }, permissionMode: { type: "string", enum: [...ACPX_PERMISSION_MODES], }, nonInteractivePermissions: { type: "string", enum: [...ACPX_NON_INTERACTIVE_POLICIES], }, timeoutSeconds: { type: "number", minimum: 0.001 }, queueOwnerTtlSeconds: { type: "number", minimum: 0 }, mcpBridge: { type: "object", additionalProperties: false, properties: { enabled: { type: "boolean" }, mode: { type: "string", enum: [...ACPX_MCP_BRIDGE_MODES], }, allowTools: { type: "array", items: { type: "string", minLength: 1 }, }, denyTools: { type: "array", items: { type: "string", minLength: 1 }, }, }, }, }, }, }; } function resolveMcpBridgeConfig(config?: AcpxMcpBridgeConfig): ResolvedAcpxMcpBridgeConfig { return { enabled: config?.enabled === true, mode: config?.mode ?? DEFAULT_MCP_BRIDGE_MODE, allowTools: config?.allowTools ?? [], denyTools: config?.denyTools ?? [], }; } export function resolveAcpxPluginConfig(params: { rawConfig: unknown; workspaceDir?: string; }): ResolvedAcpxPluginConfig { const parsed = parseAcpxPluginConfig(params.rawConfig); if (!parsed.ok) { throw new Error(parsed.message); } const normalized = parsed.value ?? {}; const fallbackCwd = params.workspaceDir?.trim() || process.cwd(); const cwd = path.resolve(normalized.cwd?.trim() || fallbackCwd); return { command: ACPX_BUNDLED_BIN, cwd, permissionMode: normalized.permissionMode ?? DEFAULT_PERMISSION_MODE, nonInteractivePermissions: normalized.nonInteractivePermissions ?? DEFAULT_NON_INTERACTIVE_POLICY, timeoutSeconds: normalized.timeoutSeconds, queueOwnerTtlSeconds: normalized.queueOwnerTtlSeconds ?? DEFAULT_QUEUE_OWNER_TTL_SECONDS, mcpBridge: resolveMcpBridgeConfig(normalized.mcpBridge), }; }