| import path from "node:path"; |
| import { fileURLToPath } from "node:url"; |
| import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk/acpx"; |
|
|
| 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_PINNED_VERSION = "0.1.16"; |
| export const ACPX_VERSION_ANY = "any"; |
| 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 function buildAcpxLocalInstallCommand(version: string = ACPX_PINNED_VERSION): string { |
| return `npm install --omit=dev --no-save acpx@${version}`; |
| } |
| export const ACPX_LOCAL_INSTALL_COMMAND = buildAcpxLocalInstallCommand(); |
|
|
| export type McpServerConfig = { |
| command: string; |
| args?: string[]; |
| env?: Record<string, string>; |
| }; |
|
|
| export type AcpxMcpServer = { |
| name: string; |
| command: string; |
| args: string[]; |
| env: Array<{ name: string; value: string }>; |
| }; |
|
|
| export type AcpxPluginConfig = { |
| command?: string; |
| expectedVersion?: string; |
| cwd?: string; |
| permissionMode?: AcpxPermissionMode; |
| nonInteractivePermissions?: AcpxNonInteractivePermissionPolicy; |
| strictWindowsCmdWrapper?: boolean; |
| timeoutSeconds?: number; |
| queueOwnerTtlSeconds?: number; |
| mcpServers?: Record<string, McpServerConfig>; |
| }; |
|
|
| export type ResolvedAcpxPluginConfig = { |
| command: string; |
| expectedVersion?: string; |
| allowPluginLocalInstall: boolean; |
| stripProviderAuthEnvVars: boolean; |
| installCommand: string; |
| cwd: string; |
| permissionMode: AcpxPermissionMode; |
| nonInteractivePermissions: AcpxNonInteractivePermissionPolicy; |
| strictWindowsCmdWrapper: boolean; |
| timeoutSeconds?: number; |
| queueOwnerTtlSeconds: number; |
| mcpServers: Record<string, McpServerConfig>; |
| }; |
|
|
| const DEFAULT_PERMISSION_MODE: AcpxPermissionMode = "approve-reads"; |
| const DEFAULT_NON_INTERACTIVE_POLICY: AcpxNonInteractivePermissionPolicy = "fail"; |
| const DEFAULT_QUEUE_OWNER_TTL_SECONDS = 0.1; |
| const DEFAULT_STRICT_WINDOWS_CMD_WRAPPER = true; |
|
|
| type ParseResult = |
| | { ok: true; value: AcpxPluginConfig | undefined } |
| | { ok: false; message: string }; |
|
|
| function isRecord(value: unknown): value is Record<string, unknown> { |
| 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 isMcpServerConfig(value: unknown): value is McpServerConfig { |
| if (!isRecord(value)) { |
| return false; |
| } |
| if (typeof value.command !== "string" || value.command.trim() === "") { |
| return false; |
| } |
| if (value.args !== undefined) { |
| if (!Array.isArray(value.args)) { |
| return false; |
| } |
| for (const arg of value.args) { |
| if (typeof arg !== "string") { |
| return false; |
| } |
| } |
| } |
| if (value.env !== undefined) { |
| if (!isRecord(value.env)) { |
| return false; |
| } |
| for (const envValue of Object.values(value.env)) { |
| if (typeof envValue !== "string") { |
| return false; |
| } |
| } |
| } |
| return true; |
| } |
|
|
| 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([ |
| "command", |
| "expectedVersion", |
| "cwd", |
| "permissionMode", |
| "nonInteractivePermissions", |
| "strictWindowsCmdWrapper", |
| "timeoutSeconds", |
| "queueOwnerTtlSeconds", |
| "mcpServers", |
| ]); |
| for (const key of Object.keys(value)) { |
| if (!allowedKeys.has(key)) { |
| return { ok: false, message: `unknown config key: ${key}` }; |
| } |
| } |
|
|
| const command = value.command; |
| if (command !== undefined && (typeof command !== "string" || command.trim() === "")) { |
| return { ok: false, message: "command must be a non-empty string" }; |
| } |
|
|
| const expectedVersion = value.expectedVersion; |
| if ( |
| expectedVersion !== undefined && |
| (typeof expectedVersion !== "string" || expectedVersion.trim() === "") |
| ) { |
| return { ok: false, message: "expectedVersion must be a non-empty string" }; |
| } |
|
|
| 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 strictWindowsCmdWrapper = value.strictWindowsCmdWrapper; |
| if (strictWindowsCmdWrapper !== undefined && typeof strictWindowsCmdWrapper !== "boolean") { |
| return { ok: false, message: "strictWindowsCmdWrapper must be a boolean" }; |
| } |
|
|
| 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 mcpServers = value.mcpServers; |
| if (mcpServers !== undefined) { |
| if (!isRecord(mcpServers)) { |
| return { ok: false, message: "mcpServers must be an object" }; |
| } |
| for (const [key, serverConfig] of Object.entries(mcpServers)) { |
| if (!isMcpServerConfig(serverConfig)) { |
| return { |
| ok: false, |
| message: `mcpServers.${key} must have a command string, optional args array, and optional env object`, |
| }; |
| } |
| } |
| } |
|
|
| return { |
| ok: true, |
| value: { |
| command: typeof command === "string" ? command.trim() : undefined, |
| expectedVersion: typeof expectedVersion === "string" ? expectedVersion.trim() : undefined, |
| cwd: typeof cwd === "string" ? cwd.trim() : undefined, |
| permissionMode: typeof permissionMode === "string" ? permissionMode : undefined, |
| nonInteractivePermissions: |
| typeof nonInteractivePermissions === "string" ? nonInteractivePermissions : undefined, |
| strictWindowsCmdWrapper: |
| typeof strictWindowsCmdWrapper === "boolean" ? strictWindowsCmdWrapper : undefined, |
| timeoutSeconds: typeof timeoutSeconds === "number" ? timeoutSeconds : undefined, |
| queueOwnerTtlSeconds: |
| typeof queueOwnerTtlSeconds === "number" ? queueOwnerTtlSeconds : undefined, |
| mcpServers: mcpServers as Record<string, McpServerConfig> | undefined, |
| }, |
| }; |
| } |
|
|
| function resolveConfiguredCommand(params: { configured?: string; workspaceDir?: string }): string { |
| const configured = params.configured?.trim(); |
| if (!configured) { |
| return ACPX_BUNDLED_BIN; |
| } |
| if (path.isAbsolute(configured) || configured.includes(path.sep) || configured.includes("/")) { |
| const baseDir = params.workspaceDir?.trim() || process.cwd(); |
| return path.resolve(baseDir, configured); |
| } |
| return configured; |
| } |
|
|
| export function createAcpxPluginConfigSchema(): OpenClawPluginConfigSchema { |
| return { |
| safeParse(value: unknown): |
| | { success: true; data?: unknown } |
| | { |
| success: false; |
| error: { issues: Array<{ path: Array<string | number>; 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: { |
| command: { type: "string" }, |
| expectedVersion: { type: "string" }, |
| cwd: { type: "string" }, |
| permissionMode: { |
| type: "string", |
| enum: [...ACPX_PERMISSION_MODES], |
| }, |
| nonInteractivePermissions: { |
| type: "string", |
| enum: [...ACPX_NON_INTERACTIVE_POLICIES], |
| }, |
| strictWindowsCmdWrapper: { type: "boolean" }, |
| timeoutSeconds: { type: "number", minimum: 0.001 }, |
| queueOwnerTtlSeconds: { type: "number", minimum: 0 }, |
| mcpServers: { |
| type: "object", |
| additionalProperties: { |
| type: "object", |
| properties: { |
| command: { type: "string" }, |
| args: { |
| type: "array", |
| items: { type: "string" }, |
| }, |
| env: { |
| type: "object", |
| additionalProperties: { type: "string" }, |
| }, |
| }, |
| required: ["command"], |
| }, |
| }, |
| }, |
| }, |
| }; |
| } |
|
|
| export function toAcpMcpServers(mcpServers: Record<string, McpServerConfig>): AcpxMcpServer[] { |
| return Object.entries(mcpServers).map(([name, server]) => ({ |
| name, |
| command: server.command, |
| args: [...(server.args ?? [])], |
| env: Object.entries(server.env ?? {}).map(([envName, value]) => ({ |
| name: envName, |
| value, |
| })), |
| })); |
| } |
|
|
| 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); |
| const command = resolveConfiguredCommand({ |
| configured: normalized.command, |
| workspaceDir: params.workspaceDir, |
| }); |
| const allowPluginLocalInstall = command === ACPX_BUNDLED_BIN; |
| const stripProviderAuthEnvVars = command === ACPX_BUNDLED_BIN; |
| const configuredExpectedVersion = normalized.expectedVersion; |
| const expectedVersion = |
| configuredExpectedVersion === ACPX_VERSION_ANY |
| ? undefined |
| : (configuredExpectedVersion ?? (allowPluginLocalInstall ? ACPX_PINNED_VERSION : undefined)); |
| const installCommand = buildAcpxLocalInstallCommand(expectedVersion ?? ACPX_PINNED_VERSION); |
|
|
| return { |
| command, |
| expectedVersion, |
| allowPluginLocalInstall, |
| stripProviderAuthEnvVars, |
| installCommand, |
| cwd, |
| permissionMode: normalized.permissionMode ?? DEFAULT_PERMISSION_MODE, |
| nonInteractivePermissions: |
| normalized.nonInteractivePermissions ?? DEFAULT_NON_INTERACTIVE_POLICY, |
| strictWindowsCmdWrapper: |
| normalized.strictWindowsCmdWrapper ?? DEFAULT_STRICT_WINDOWS_CMD_WRAPPER, |
| timeoutSeconds: normalized.timeoutSeconds, |
| queueOwnerTtlSeconds: normalized.queueOwnerTtlSeconds ?? DEFAULT_QUEUE_OWNER_TTL_SECONDS, |
| mcpServers: normalized.mcpServers ?? {}, |
| }; |
| } |
|
|