UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

525 lines (524 loc) 22 kB
import { a as normalizeLowercaseStringOrEmpty } from "./string-coerce-mnp54Vah.js"; import { At as boolean, Et as array, Ln as strictObject, Nn as record, Rn as string, Tn as object, wn as number, yt as _enum } from "./schemas-6cH6bZ7o.js"; import "./string-coerce-runtime-CEGJWkQ_.js"; import { s as formatPluginConfigIssue } from "./extension-shared-B8fkO3TV.js"; import "./process-lease-DiKkFj6F.js"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import fs from "node:fs"; import path from "node:path"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; //#region extensions/acpx/src/command-line.ts /** * Small shell-command helpers for ACPX-launched processes. Splitting supports * simple quoted command strings from config without invoking a shell parser. */ /** Quote one command argument for display or config serialization. */ function quoteCommandPart(value) { return JSON.stringify(value); } /** Split a command string into argv-like parts using simple quote/backslash rules. */ function splitCommandParts(value) { const parts = []; let current = ""; let quote = null; let escaping = false; for (const ch of value) { if (escaping) { current += ch; escaping = false; continue; } if (ch === "\\" && quote !== "'") { escaping = true; continue; } if (quote) { if (ch === quote) quote = null; else current += ch; continue; } if (ch === "'" || ch === "\"") { quote = ch; continue; } if (/\s/.test(ch)) { if (current) { parts.push(current); current = ""; } continue; } current += ch; } if (escaping) current += "\\"; if (current) parts.push(current); return parts; } //#endregion //#region extensions/acpx/src/config-schema.ts /** * ACPX plugin configuration schema and public config types. Runtime setup uses * this file as the single source of truth for validation and defaulting. */ const ACPX_PERMISSION_MODES = [ "approve-all", "approve-reads", "deny-all" ]; const ACPX_NON_INTERACTIVE_POLICIES = ["deny", "fail"]; const nonEmptyTrimmedString = (message) => string({ error: message }).trim().min(1, { error: message }); const McpServerConfigSchema = object({ command: nonEmptyTrimmedString("command must be a non-empty string").describe("Command to run the MCP server"), args: array(string({ error: "args must be an array of strings" }), { error: "args must be an array of strings" }).optional().describe("Arguments to pass to the command"), env: record(string(), string({ error: "env values must be strings" }), { error: "env must be an object of strings" }).optional().describe("Environment variables for the MCP server") }); /** Zod schema for validating raw ACPX plugin config from OpenClaw config. */ const AcpxPluginConfigSchema = strictObject({ cwd: nonEmptyTrimmedString("cwd must be a non-empty string").optional(), stateDir: nonEmptyTrimmedString("stateDir must be a non-empty string").optional(), probeAgent: nonEmptyTrimmedString("probeAgent must be a non-empty string").optional(), permissionMode: _enum(ACPX_PERMISSION_MODES, { error: `permissionMode must be one of: ${ACPX_PERMISSION_MODES.join(", ")}` }).optional(), nonInteractivePermissions: _enum(ACPX_NON_INTERACTIVE_POLICIES, { error: `nonInteractivePermissions must be one of: ${ACPX_NON_INTERACTIVE_POLICIES.join(", ")}` }).optional(), pluginToolsMcpBridge: boolean({ error: "pluginToolsMcpBridge must be a boolean" }).optional(), openClawToolsMcpBridge: boolean({ error: "openClawToolsMcpBridge must be a boolean" }).optional(), strictWindowsCmdWrapper: boolean({ error: "strictWindowsCmdWrapper must be a boolean" }).optional(), timeoutSeconds: number({ error: "timeoutSeconds must be a number >= 0.001" }).min(.001, { error: "timeoutSeconds must be a number >= 0.001" }).default(120), queueOwnerTtlSeconds: number({ error: "queueOwnerTtlSeconds must be a number >= 0" }).min(0, { error: "queueOwnerTtlSeconds must be a number >= 0" }).optional(), mcpServers: record(string(), McpServerConfigSchema).optional(), agents: record(string(), strictObject({ command: nonEmptyTrimmedString("agents.<id>.command must be a non-empty string"), args: array(string({ error: "args must be an array of strings" })).optional() })).optional() }); //#endregion //#region extensions/acpx/src/config.ts /** * Resolves ACPX plugin config from raw user configuration. It locates the * plugin root, injects optional MCP bridge servers, and applies runtime defaults. */ const ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME = "openclaw-plugin-tools"; const ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME = "openclaw-tools"; const requireFromHere$1 = createRequire(import.meta.url); function isAcpxPluginRoot(dir) { return fs.existsSync(path.join(dir, "openclaw.plugin.json")) && fs.existsSync(path.join(dir, "package.json")); } function resolveNearestAcpxPluginRoot(moduleUrl) { let cursor = path.dirname(fileURLToPath(moduleUrl)); for (let i = 0; i < 3; i += 1) { 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) { 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; } function resolveRepoAcpxPluginRoot(currentRoot) { const workspaceRoot = path.join(currentRoot, "extensions", "acpx"); return isAcpxPluginRoot(workspaceRoot) ? workspaceRoot : null; } function resolveAcpxPluginRootFromOpenClawLayout(moduleUrl) { let cursor = path.dirname(fileURLToPath(moduleUrl)); for (let i = 0; i < 5; i += 1) { const candidates = [ path.join(cursor, "extensions", "acpx"), path.join(cursor, "dist", "extensions", "acpx"), path.join(cursor, "dist-runtime", "extensions", "acpx") ]; for (const candidate of candidates) if (isAcpxPluginRoot(candidate)) return candidate; const parent = path.dirname(cursor); if (parent === cursor) break; cursor = parent; } return null; } /** Resolve the ACPX plugin root across source, dist, and dist-runtime layouts. */ function resolveAcpxPluginRoot(moduleUrl = import.meta.url) { const resolvedRoot = resolveNearestAcpxPluginRoot(moduleUrl); return resolveWorkspaceAcpxPluginRoot(resolvedRoot) ?? resolveRepoAcpxPluginRoot(resolvedRoot) ?? resolveAcpxPluginRootFromOpenClawLayout(moduleUrl) ?? resolvedRoot; } const DEFAULT_PERMISSION_MODE = "approve-reads"; const DEFAULT_NON_INTERACTIVE_POLICY = "fail"; const DEFAULT_QUEUE_OWNER_TTL_SECONDS = .1; const DEFAULT_STRICT_WINDOWS_CMD_WRAPPER = true; function parseAcpxPluginConfig(value) { if (value === void 0) return { ok: true, value: void 0 }; const parsed = AcpxPluginConfigSchema.safeParse(value); if (!parsed.success) return { ok: false, message: formatPluginConfigIssue(parsed.error.issues[0]) }; return { ok: true, value: parsed.data }; } function resolveOpenClawRoot(currentRoot) { if (path.basename(currentRoot) === "acpx" && path.basename(path.dirname(currentRoot)) === "extensions") { const parent = path.dirname(path.dirname(currentRoot)); if (path.basename(parent) === "dist") return path.dirname(parent); return parent; } return path.resolve(currentRoot, ".."); } function resolveTsxImportSpecifier() { try { return requireFromHere$1.resolve("tsx"); } catch { return "tsx"; } } function shellQuoteCommandArg(arg) { if (!/[\s'"\\$|&;<>{}()*?[\]~`]/.test(arg)) return arg; return `'${arg.replace(/'/g, "'\"'\"'")}'`; } function resolvePluginToolsMcpServerConfig(moduleUrl = import.meta.url) { const openClawRoot = resolveOpenClawRoot(resolveAcpxPluginRoot(moduleUrl)); const distEntry = path.join(openClawRoot, "dist", "mcp", "plugin-tools-serve.js"); if (fs.existsSync(distEntry)) return { command: process.execPath, args: [distEntry] }; const sourceEntry = path.join(openClawRoot, "src", "mcp", "plugin-tools-serve.ts"); return { command: process.execPath, args: [ "--import", resolveTsxImportSpecifier(), sourceEntry ] }; } function resolveOpenClawToolsMcpServerConfig(moduleUrl = import.meta.url) { const openClawRoot = resolveOpenClawRoot(resolveAcpxPluginRoot(moduleUrl)); const distEntry = path.join(openClawRoot, "dist", "mcp", "openclaw-tools-serve.js"); if (fs.existsSync(distEntry)) return { command: process.execPath, args: [distEntry] }; const sourceEntry = path.join(openClawRoot, "src", "mcp", "openclaw-tools-serve.ts"); return { command: process.execPath, args: [ "--import", resolveTsxImportSpecifier(), sourceEntry ] }; } function resolveConfiguredMcpServers(params) { const resolved = { ...params.mcpServers }; if (params.pluginToolsMcpBridge && resolved[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME]) throw new Error(`mcpServers.${ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME} is reserved when pluginToolsMcpBridge=true`); if (params.openClawToolsMcpBridge && resolved[ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME]) throw new Error(`mcpServers.${ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME} is reserved when openClawToolsMcpBridge=true`); if (params.pluginToolsMcpBridge) resolved[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME] = resolvePluginToolsMcpServerConfig(params.moduleUrl); if (params.openClawToolsMcpBridge) resolved[ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME] = resolveOpenClawToolsMcpServerConfig(params.moduleUrl); return resolved; } /** Convert OpenClaw MCP server config into ACPX runtime MCP server entries. */ function toAcpMcpServers(mcpServers) { 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 })) })); } /** Validate and normalize raw ACPX plugin config for runtime startup. */ function resolveAcpxPluginConfig(params) { const parsed = parseAcpxPluginConfig(params.rawConfig); if (!parsed.ok) throw new Error(parsed.message); const normalized = parsed.value ?? {}; const workspaceDir = params.workspaceDir?.trim() || process.cwd(); const fallbackCwd = workspaceDir; const cwd = path.resolve(normalized.cwd?.trim() || fallbackCwd); const stateDir = path.resolve(normalized.stateDir?.trim() || path.join(workspaceDir, "state")); const pluginToolsMcpBridge = normalized.pluginToolsMcpBridge === true; const openClawToolsMcpBridge = normalized.openClawToolsMcpBridge === true; const mcpServers = resolveConfiguredMcpServers({ mcpServers: normalized.mcpServers, pluginToolsMcpBridge, openClawToolsMcpBridge, moduleUrl: params.moduleUrl }); const agents = Object.fromEntries(Object.entries(normalized.agents ?? {}).map(([name, entry]) => { const cmd = entry.command.trim(); const cmdArgs = entry.args ?? []; const fullCommand = cmdArgs.length > 0 ? `${cmd} ${cmdArgs.map(shellQuoteCommandArg).join(" ")}` : cmd; return [normalizeLowercaseStringOrEmpty(name), fullCommand]; })); return { cwd, stateDir, probeAgent: normalizeLowercaseStringOrEmpty(normalized.probeAgent) || void 0, permissionMode: normalized.permissionMode ?? DEFAULT_PERMISSION_MODE, nonInteractivePermissions: normalized.nonInteractivePermissions ?? DEFAULT_NON_INTERACTIVE_POLICY, pluginToolsMcpBridge, openClawToolsMcpBridge, strictWindowsCmdWrapper: normalized.strictWindowsCmdWrapper ?? DEFAULT_STRICT_WINDOWS_CMD_WRAPPER, timeoutSeconds: normalized.timeoutSeconds ?? 120, queueOwnerTtlSeconds: normalized.queueOwnerTtlSeconds ?? DEFAULT_QUEUE_OWNER_TTL_SECONDS, legacyCompatibilityConfig: { strictWindowsCmdWrapper: normalized.strictWindowsCmdWrapper, queueOwnerTtlSeconds: normalized.queueOwnerTtlSeconds }, mcpServers, agents }; } //#endregion //#region extensions/acpx/src/process-reaper.ts /** * ACPX process ownership checks and cleanup. The reaper only terminates * OpenClaw-owned wrapper trees after validating paths, packages, and lease ids. */ const execFileAsync = promisify(execFile); const requireFromHere = createRequire(import.meta.url); const GENERATED_WRAPPER_BASENAMES = new Set(["codex-acp-wrapper.mjs", "claude-agent-acp-wrapper.mjs"]); const OPENCLAW_PLUGIN_DEPS_MARKER = "/plugin-runtime-deps/"; const OWNED_ACP_PACKAGE_NAMES = [ "@zed-industries/codex-acp", "@zed-industries/codex-acp-darwin-arm64", "@zed-industries/codex-acp-darwin-x64", "@zed-industries/codex-acp-linux-arm64", "@zed-industries/codex-acp-linux-x64", "@zed-industries/codex-acp-win32-arm64", "@zed-industries/codex-acp-win32-x64", "@agentclientprotocol/claude-agent-acp", "acpx" ]; const ACP_PACKAGE_MARKERS = [...OWNED_ACP_PACKAGE_NAMES.map((packageName) => `/node_modules/${packageName}/`), "/acpx/dist/"]; function normalizePathLike(value) { return value.replaceAll("\\", "/"); } function resolvePackageRoot(packageName) { try { return normalizePathLike(path.dirname(requireFromHere.resolve(`${packageName}/package.json`))); } catch { return; } } function resolveOpenClawInstallRoot(pluginRoot) { if (path.basename(pluginRoot) === "acpx" && path.basename(path.dirname(pluginRoot)) === "extensions") { const parent = path.dirname(path.dirname(pluginRoot)); return path.basename(parent) === "dist" ? path.dirname(parent) : parent; } return path.resolve(pluginRoot, ".."); } function resolveOwnedAcpPackageRootCandidates(packageName) { const pluginRoot = resolveAcpxPluginRoot(import.meta.url); const openClawRoot = resolveOpenClawInstallRoot(pluginRoot); return [ resolvePackageRoot(packageName), path.join(pluginRoot, "node_modules", packageName), path.join(openClawRoot, "node_modules", packageName) ].flatMap((root) => root ? [normalizePathLike(root)] : []); } const OWNED_ACP_PACKAGE_ROOTS = Array.from(new Set(OWNED_ACP_PACKAGE_NAMES.flatMap(resolveOwnedAcpPackageRootCandidates))); function commandBelongsToResolvedAcpPackage(command) { return OWNED_ACP_PACKAGE_ROOTS.some((root) => command.includes(`${root}/`)); } function commandMentionsGeneratedWrapper(command) { return Array.from(GENERATED_WRAPPER_BASENAMES).some((basename) => command.includes(basename)); } function commandWrapperBelongsToRoot(command, wrapperRoot) { if (!wrapperRoot) return true; const normalizedCommand = normalizePathLike(command); const normalizedRoot = normalizePathLike(wrapperRoot).replace(/\/+$/, ""); return Array.from(GENERATED_WRAPPER_BASENAMES).some((basename) => normalizedCommand.includes(`${normalizedRoot}/${basename}`)); } /** Check whether a command references an OpenClaw-generated ACPX wrapper path. */ function isOpenClawLeaseAwareAcpxProcessCommand(params) { const command = params.command?.trim(); if (!command) return false; const normalized = normalizePathLike(command); return commandMentionsGeneratedWrapper(normalized) && commandWrapperBelongsToRoot(normalized, params.wrapperRoot); } function commandsReferToSameRootCommand(liveCommand, storedCommand) { if (!storedCommand?.trim()) return true; return normalizePathLike(liveCommand).trim() === normalizePathLike(storedCommand).trim(); } function commandOptionEquals(parts, option, expected) { if (!expected) return true; const index = parts.indexOf(option); return index >= 0 && parts[index + 1] === expected; } function liveCommandMatchesLeaseIdentity(params) { if (!params.expectedLeaseId && !params.expectedGatewayInstanceId) return true; const parts = splitCommandParts(params.command ?? ""); return commandOptionEquals(parts, "--openclaw-acpx-lease-id", params.expectedLeaseId) && commandOptionEquals(parts, "--openclaw-gateway-instance-id", params.expectedGatewayInstanceId); } /** Check whether a command is owned by OpenClaw ACPX runtime packages or wrappers. */ function isOpenClawOwnedAcpxProcessCommand(params) { const command = params.command?.trim(); if (!command) return false; const normalized = normalizePathLike(command); if (isOpenClawLeaseAwareAcpxProcessCommand({ command: normalized, wrapperRoot: params.wrapperRoot })) return true; if (commandBelongsToResolvedAcpPackage(normalized)) return true; if (!normalized.includes(OPENCLAW_PLUGIN_DEPS_MARKER)) return false; return ACP_PACKAGE_MARKERS.some((marker) => normalized.includes(marker)); } function parseProcessList(stdout) { const processes = []; for (const line of stdout.split(/\r?\n/)) { const match = /^\s*(?<pid>\d+)\s+(?<ppid>\d+)\s+(?<command>.+?)\s*$/.exec(line); if (!match?.groups) continue; processes.push({ pid: Number.parseInt(match.groups.pid, 10), ppid: Number.parseInt(match.groups.ppid, 10), command: match.groups.command }); } return processes; } /** List host processes in the compact shape needed by ACPX cleanup. */ async function listPlatformProcesses() { if (process.platform === "win32") return []; const { stdout } = await execFileAsync("ps", ["-axo", "pid=,ppid=,command="], { maxBuffer: 8 * 1024 * 1024 }); return parseProcessList(stdout); } function collectProcessTree(processes, rootPid) { const childrenByParent = /* @__PURE__ */ new Map(); for (const processInfo of processes) { const children = childrenByParent.get(processInfo.ppid) ?? []; children.push(processInfo); childrenByParent.set(processInfo.ppid, children); } const root = new Map(processes.map((processInfo) => [processInfo.pid, processInfo])).get(rootPid); const collected = []; if (root) collected.push(root); const queue = [...childrenByParent.get(rootPid) ?? []]; while (queue.length > 0) { const next = queue.shift(); if (!next || collected.some((processInfo) => processInfo.pid === next.pid)) continue; collected.push(next); queue.push(...childrenByParent.get(next.pid) ?? []); } return collected; } function uniquePids(processes) { return Array.from(new Set(processes.map((processInfo) => processInfo.pid).filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid))); } function isProcessAlive(pid) { try { process.kill(pid, 0); return true; } catch { return false; } } async function terminatePids(pids, deps) { const killProcess = deps?.killProcess ?? ((pid, signal) => process.kill(pid, signal)); const sleep = deps?.sleep ?? ((ms) => new Promise((resolve) => { setTimeout(resolve, ms); })); const terminated = []; for (const pid of pids) try { killProcess(pid, "SIGTERM"); terminated.push(pid); } catch {} if (terminated.length === 0) return terminated; await sleep(750); for (const pid of terminated) if (deps?.killProcess || isProcessAlive(pid)) try { killProcess(pid, "SIGKILL"); } catch {} return terminated; } /** Terminate one validated OpenClaw-owned ACPX wrapper process tree. */ async function cleanupOpenClawOwnedAcpxProcessTree(params) { const rootPid = params.rootPid; if (!rootPid || rootPid <= 0 || rootPid === process.pid) return { inspectedPids: [], terminatedPids: [], skippedReason: "missing-root" }; let processes; try { processes = await (params.deps?.listProcesses ?? listPlatformProcesses)(); } catch { processes = []; } const listedTree = collectProcessTree(processes, rootPid); if (listedTree.length === 0) return { inspectedPids: [], terminatedPids: [], skippedReason: "unverified-root" }; const rootCommand = listedTree[0]?.command ?? params.rootCommand; const liveCommandWasGeneratedWrapper = commandMentionsGeneratedWrapper(normalizePathLike(rootCommand ?? "")); const storedCommandWasGeneratedWrapper = commandMentionsGeneratedWrapper(normalizePathLike(params.rootCommand ?? "")); if (!liveCommandWasGeneratedWrapper && storedCommandWasGeneratedWrapper) return { inspectedPids: listedTree.map((processInfo) => processInfo.pid), terminatedPids: [], skippedReason: "not-openclaw-owned" }; if (!liveCommandWasGeneratedWrapper && !commandsReferToSameRootCommand(rootCommand ?? "", params.rootCommand)) return { inspectedPids: listedTree.map((processInfo) => processInfo.pid), terminatedPids: [], skippedReason: "not-openclaw-owned" }; if (!isOpenClawOwnedAcpxProcessCommand({ command: rootCommand, wrapperRoot: params.wrapperRoot })) return { inspectedPids: listedTree.map((processInfo) => processInfo.pid), terminatedPids: [], skippedReason: "not-openclaw-owned" }; if (!liveCommandMatchesLeaseIdentity({ command: rootCommand, expectedLeaseId: params.expectedLeaseId, expectedGatewayInstanceId: params.expectedGatewayInstanceId })) return { inspectedPids: listedTree.map((processInfo) => processInfo.pid), terminatedPids: [], skippedReason: "not-openclaw-owned" }; const pids = uniquePids(listedTree.toReversed()); return { inspectedPids: uniquePids(listedTree), terminatedPids: await terminatePids(pids, params.deps) }; } /** Reap orphaned OpenClaw-owned ACPX wrapper trees during runtime startup. */ async function reapStaleOpenClawOwnedAcpxOrphans(params) { if (process.platform === "win32") return { inspectedPids: [], terminatedPids: [], skippedReason: "unsupported-platform" }; let processes; try { processes = await (params.deps?.listProcesses ?? listPlatformProcesses)(); } catch { return { inspectedPids: [], terminatedPids: [], skippedReason: "process-list-unavailable" }; } const orphanTrees = processes.filter((processInfo) => processInfo.ppid === 1 && isOpenClawOwnedAcpxProcessCommand({ command: processInfo.command, wrapperRoot: params.wrapperRoot })).map((orphan) => collectProcessTree(processes, orphan.pid)); return { inspectedPids: uniquePids(orphanTrees.flat()), terminatedPids: await terminatePids(uniquePids(orphanTrees.flatMap((tree) => tree.toReversed())), params.deps) }; } //#endregion export { resolveAcpxPluginRoot as a, splitCommandParts as c, resolveAcpxPluginConfig as i, isOpenClawLeaseAwareAcpxProcessCommand as n, toAcpMcpServers as o, reapStaleOpenClawOwnedAcpxOrphans as r, quoteCommandPart as s, cleanupOpenClawOwnedAcpxProcessTree as t };