import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { OpenClawPluginConfigSchema } from "../runtime-api.js"; 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"; function isAcpxPluginRoot(dir: string): boolean { return ( fs.existsSync(path.join(dir, "openclaw.plugin.json")) && fs.existsSync(path.join(dir, "package.json")) ); } function resolveNearestAcpxPluginRoot(moduleUrl: string): string { let cursor = path.dirname(fileURLToPath(moduleUrl)); for (let i = 0; i < 3; i += 1) { // Bundled entries live at the plugin root while source files still live under src/. if (isAcpxPluginRoot(cursor)) { return cursor; } const parent = path.dirname(cursor); if (parent === cursor) { break; } cursor = parent; } return path.resolve(path.dirname(fileURLToPath(moduleUrl)), ".."); } function resolveWorkspaceAcpxPluginRoot(currentRoot: string): string | null { if ( path.basename(currentRoot) !== "acpx" || path.basename(path.dirname(currentRoot)) !== "extensions" || path.basename(path.dirname(path.dirname(currentRoot))) !== "dist" ) { return null; } const workspaceRoot = path.resolve(currentRoot, "..", "..", "..", "extensions", "acpx"); return isAcpxPluginRoot(workspaceRoot) ? workspaceRoot : null; } export function resolveAcpxPluginRoot(moduleUrl: string = import.meta.url): string { const resolvedRoot = resolveNearestAcpxPluginRoot(moduleUrl); // In a live repo checkout, dist/ can be rebuilt out from under the running gateway. // Prefer the stable source plugin root when a built extension is running beside it. return resolveWorkspaceAcpxPluginRoot(resolvedRoot) ?? resolvedRoot; } export const ACPX_PLUGIN_ROOT = resolveAcpxPluginRoot(); 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 --package-lock=false acpx@${version}`; } export const ACPX_LOCAL_INSTALL_COMMAND = buildAcpxLocalInstallCommand(); export type McpServerConfig = { command: string; args?: string[]; env?: Record; }; 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; }; 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; }; 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 { 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 | 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; 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): 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 ?? {}, }; }