Files
openclaw/extensions/acpx/src/config.ts
Bob ea15819ecf ACP: harden startup and move configured routing behind plugin seams (#48197)
* ACPX: keep plugin-local runtime installs out of dist

* Gateway: harden ACP startup and service PATH

* ACP: reinitialize error-state configured bindings

* ACP: classify pre-turn runtime failures as session init failures

* Plugins: move configured ACP routing behind channel seams

* Telegram tests: align startup probe assertions after rebase

* Discord: harden ACP configured binding recovery

* ACP: recover Discord bindings after stale runtime exits

* ACPX: replace dead sessions during ensure

* Discord: harden ACP binding recovery

* Discord: fix review follow-ups

* ACP bindings: load channel snapshots across workspaces

* ACP bindings: cache snapshot channel plugin resolution

* Experiments: add ACP pluginification holy grail plan

* Experiments: rename ACP pluginification plan doc

* Experiments: drop old ACP pluginification doc path

* ACP: move configured bindings behind plugin services

* Experiments: update bindings capability architecture plan

* Bindings: isolate configured binding routing and targets

* Discord tests: fix runtime env helper path

* Tests: fix channel binding CI regressions

* Tests: normalize ACP workspace assertion on Windows

* Bindings: isolate configured binding registry

* Bindings: finish configured binding cleanup

* Bindings: finish generic cleanup

* Bindings: align runtime approval callbacks

* ACP: delete residual bindings barrel

* Bindings: restore legacy compatibility

* Revert "Bindings: restore legacy compatibility"

This reverts commit ac2ed68fa2426ecc874d68278c71c71ad363fcfe.

* Tests: drop ACP route legacy helper names

* Discord/ACP: fix binding regressions

---------

Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com>
2026-03-17 17:27:52 +01:00

405 lines
13 KiB
TypeScript

import fs from "node:fs";
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";
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<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 ?? {},
};
}