From 921ebfb25e9ad95cc786dd90ff97dc0bd7288866 Mon Sep 17 00:00:00 2001 From: Onur <2453968+osolmaz@users.noreply.github.com> Date: Sat, 28 Feb 2026 10:37:02 +0100 Subject: [PATCH] ACPX plugin: allow configurable command and expected version --- docs/tools/acp-agents.md | 41 ++++++++++---- extensions/acpx/openclaw.plugin.json | 16 +++++- extensions/acpx/src/config.test.ts | 77 ++++++++++++++++++++++---- extensions/acpx/src/config.ts | 58 +++++++++++++++++++- extensions/acpx/src/ensure.test.ts | 82 +++++++++++++++++++++++++--- extensions/acpx/src/ensure.ts | 39 +++++++------ extensions/acpx/src/runtime.test.ts | 6 ++ extensions/acpx/src/runtime.ts | 22 +++----- extensions/acpx/src/service.test.ts | 16 +++--- extensions/acpx/src/service.ts | 17 +++--- 10 files changed, 299 insertions(+), 75 deletions(-) diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index 33e59aeb15f..31e013a603a 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -314,21 +314,42 @@ Then verify backend health: /acp doctor ``` -### Pinned acpx install strategy (current behavior) +### acpx command and version configuration -`@openclaw/acpx` now enforces a strict plugin-local pinning model: +By default, `@openclaw/acpx` uses the plugin-local pinned binary: -1. The extension pins an exact acpx dependency in `extensions/acpx/package.json`. -2. Runtime command is fixed to the plugin-local binary (`extensions/acpx/node_modules/.bin/acpx`), not global `PATH`. -3. Plugin config does not expose `command` or `commandArgs`, so runtime command drift is blocked. -4. Startup registers the ACP backend immediately as not-ready. -5. A background ensure job verifies `acpx --version` against the pinned version. -6. If missing/mismatched, it runs plugin-local install (`npm install --omit=dev --no-save acpx@`) and re-verifies before healthy. +1. Command defaults to `extensions/acpx/node_modules/.bin/acpx`. +2. Expected version defaults to the extension pin. +3. Startup registers ACP backend immediately as not-ready. +4. A background ensure job verifies `acpx --version`. +5. If the plugin-local binary is missing or mismatched, it runs: + `npm install --omit=dev --no-save acpx@` and re-verifies. + +You can override command/version in plugin config: + +```json +{ + "plugins": { + "entries": { + "acpx": { + "enabled": true, + "config": { + "command": "../acpx/dist/cli.js", + "expectedVersion": "any" + } + } + } + } +} +``` Notes: -- OpenClaw startup stays non-blocking while acpx ensure runs. -- If network/install fails, backend remains unavailable and `/acp doctor` reports an actionable fix. +- `command` accepts an absolute path, relative path, or command name (`acpx`). +- Relative paths resolve from OpenClaw workspace directory. +- `expectedVersion: "any"` disables strict version matching. +- When `command` points to a custom binary/path, plugin-local auto-install is disabled. +- OpenClaw startup remains non-blocking while the backend health check runs. See [Plugins](/tools/plugin). diff --git a/extensions/acpx/openclaw.plugin.json b/extensions/acpx/openclaw.plugin.json index 61790e6ca05..ec271c91681 100644 --- a/extensions/acpx/openclaw.plugin.json +++ b/extensions/acpx/openclaw.plugin.json @@ -1,12 +1,18 @@ { "id": "acpx", "name": "ACPX Runtime", - "description": "ACP runtime backend powered by a pinned plugin-local acpx CLI.", + "description": "ACP runtime backend powered by acpx with configurable command path and version policy.", "skills": ["./skills"], "configSchema": { "type": "object", "additionalProperties": false, "properties": { + "command": { + "type": "string" + }, + "expectedVersion": { + "type": "string" + }, "cwd": { "type": "string" }, @@ -29,6 +35,14 @@ } }, "uiHints": { + "command": { + "label": "acpx Command", + "help": "Optional path/command override for acpx (for example /home/user/repos/acpx/dist/cli.js). Leave unset to use plugin-local bundled acpx." + }, + "expectedVersion": { + "label": "Expected acpx Version", + "help": "Exact version to enforce (for example 0.1.13) or \"any\" to skip strict version matching." + }, "cwd": { "label": "Default Working Directory", "help": "Default cwd for ACP session operations when not set per session." diff --git a/extensions/acpx/src/config.test.ts b/extensions/acpx/src/config.test.ts index efd6d5c7e73..270bf4b0ced 100644 --- a/extensions/acpx/src/config.test.ts +++ b/extensions/acpx/src/config.test.ts @@ -2,12 +2,13 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { ACPX_BUNDLED_BIN, + ACPX_PINNED_VERSION, createAcpxPluginConfigSchema, resolveAcpxPluginConfig, } from "./config.js"; describe("acpx plugin config parsing", () => { - it("resolves a strict plugin-local acpx command", () => { + it("resolves bundled acpx with pinned version by default", () => { const resolved = resolveAcpxPluginConfig({ rawConfig: { cwd: "/tmp/workspace", @@ -16,18 +17,74 @@ describe("acpx plugin config parsing", () => { }); expect(resolved.command).toBe(ACPX_BUNDLED_BIN); + expect(resolved.expectedVersion).toBe(ACPX_PINNED_VERSION); + expect(resolved.allowPluginLocalInstall).toBe(true); expect(resolved.cwd).toBe(path.resolve("/tmp/workspace")); }); - it("rejects command overrides", () => { - expect(() => - resolveAcpxPluginConfig({ - rawConfig: { - command: "acpx-custom", - }, - workspaceDir: "/tmp/workspace", - }), - ).toThrow("unknown config key: command"); + it("accepts command override and disables plugin-local auto-install", () => { + const resolved = resolveAcpxPluginConfig({ + rawConfig: { + command: "/home/user/repos/acpx/dist/cli.js", + }, + workspaceDir: "/tmp/workspace", + }); + + expect(resolved.command).toBe("/home/user/repos/acpx/dist/cli.js"); + expect(resolved.expectedVersion).toBeUndefined(); + expect(resolved.allowPluginLocalInstall).toBe(false); + }); + + it("resolves relative command paths against workspace directory", () => { + const resolved = resolveAcpxPluginConfig({ + rawConfig: { + command: "../acpx/dist/cli.js", + }, + workspaceDir: "/home/user/repos/openclaw", + }); + + expect(resolved.command).toBe(path.resolve("/home/user/repos/openclaw", "../acpx/dist/cli.js")); + expect(resolved.expectedVersion).toBeUndefined(); + expect(resolved.allowPluginLocalInstall).toBe(false); + }); + + it("keeps bare command names as-is", () => { + const resolved = resolveAcpxPluginConfig({ + rawConfig: { + command: "acpx", + }, + workspaceDir: "/tmp/workspace", + }); + + expect(resolved.command).toBe("acpx"); + expect(resolved.expectedVersion).toBeUndefined(); + expect(resolved.allowPluginLocalInstall).toBe(false); + }); + + it("accepts exact expectedVersion override", () => { + const resolved = resolveAcpxPluginConfig({ + rawConfig: { + command: "/home/user/repos/acpx/dist/cli.js", + expectedVersion: "0.1.99", + }, + workspaceDir: "/tmp/workspace", + }); + + expect(resolved.command).toBe("/home/user/repos/acpx/dist/cli.js"); + expect(resolved.expectedVersion).toBe("0.1.99"); + expect(resolved.allowPluginLocalInstall).toBe(false); + }); + + it("treats expectedVersion=any as no version constraint", () => { + const resolved = resolveAcpxPluginConfig({ + rawConfig: { + command: "/home/user/repos/acpx/dist/cli.js", + expectedVersion: "any", + }, + workspaceDir: "/tmp/workspace", + }); + + expect(resolved.expectedVersion).toBeUndefined(); }); it("rejects commandArgs overrides", () => { diff --git a/extensions/acpx/src/config.ts b/extensions/acpx/src/config.ts index bf5d0e0993e..ef061f3d9cf 100644 --- a/extensions/acpx/src/config.ts +++ b/extensions/acpx/src/config.ts @@ -9,12 +9,18 @@ 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.13"; +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 const ACPX_LOCAL_INSTALL_COMMAND = `npm install --omit=dev --no-save acpx@${ACPX_PINNED_VERSION}`; +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 AcpxPluginConfig = { + command?: string; + expectedVersion?: string; cwd?: string; permissionMode?: AcpxPermissionMode; nonInteractivePermissions?: AcpxNonInteractivePermissionPolicy; @@ -24,6 +30,9 @@ export type AcpxPluginConfig = { export type ResolvedAcpxPluginConfig = { command: string; + expectedVersion?: string; + allowPluginLocalInstall: boolean; + installCommand: string; cwd: string; permissionMode: AcpxPermissionMode; nonInteractivePermissions: AcpxNonInteractivePermissionPolicy; @@ -61,6 +70,8 @@ function parseAcpxPluginConfig(value: unknown): ParseResult { return { ok: false, message: "expected config object" }; } const allowedKeys = new Set([ + "command", + "expectedVersion", "cwd", "permissionMode", "nonInteractivePermissions", @@ -73,6 +84,19 @@ function parseAcpxPluginConfig(value: unknown): ParseResult { } } + 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" }; @@ -122,6 +146,8 @@ function parseAcpxPluginConfig(value: unknown): ParseResult { 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: @@ -133,6 +159,18 @@ function parseAcpxPluginConfig(value: unknown): ParseResult { }; } +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): @@ -156,6 +194,8 @@ export function createAcpxPluginConfigSchema(): OpenClawPluginConfigSchema { type: "object", additionalProperties: false, properties: { + command: { type: "string" }, + expectedVersion: { type: "string" }, cwd: { type: "string" }, permissionMode: { type: "string", @@ -183,9 +223,23 @@ export function resolveAcpxPluginConfig(params: { 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 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: ACPX_BUNDLED_BIN, + command, + expectedVersion, + allowPluginLocalInstall, + installCommand, cwd, permissionMode: normalized.permissionMode ?? DEFAULT_PERMISSION_MODE, nonInteractivePermissions: diff --git a/extensions/acpx/src/ensure.test.ts b/extensions/acpx/src/ensure.test.ts index 0b36c3def36..81b88da3ab5 100644 --- a/extensions/acpx/src/ensure.test.ts +++ b/extensions/acpx/src/ensure.test.ts @@ -1,8 +1,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { ACPX_LOCAL_INSTALL_COMMAND, ACPX_PINNED_VERSION } from "./config.js"; +import { + ACPX_LOCAL_INSTALL_COMMAND, + ACPX_PINNED_VERSION, + buildAcpxLocalInstallCommand, +} from "./config.js"; const { resolveSpawnFailureMock, spawnAndCollectMock } = vi.hoisted(() => ({ - resolveSpawnFailureMock: vi.fn(() => null), + resolveSpawnFailureMock: vi.fn< + (error: unknown, cwd: string) => "missing-command" | "missing-cwd" | null + >(() => null), spawnAndCollectMock: vi.fn(), })); @@ -11,7 +17,7 @@ vi.mock("./runtime-internals/process.js", () => ({ spawnAndCollect: spawnAndCollectMock, })); -import { checkPinnedAcpxVersion, ensurePinnedAcpx } from "./ensure.js"; +import { checkAcpxVersion, ensureAcpx } from "./ensure.js"; describe("acpx ensure", () => { beforeEach(() => { @@ -28,7 +34,7 @@ describe("acpx ensure", () => { error: null, }); - const result = await checkPinnedAcpxVersion({ + const result = await checkAcpxVersion({ command: "/plugin/node_modules/.bin/acpx", cwd: "/plugin", expectedVersion: ACPX_PINNED_VERSION, @@ -49,7 +55,7 @@ describe("acpx ensure", () => { error: null, }); - const result = await checkPinnedAcpxVersion({ + const result = await checkAcpxVersion({ command: "/plugin/node_modules/.bin/acpx", cwd: "/plugin", expectedVersion: ACPX_PINNED_VERSION, @@ -64,6 +70,27 @@ describe("acpx ensure", () => { }); }); + it("accepts any installed version when expectedVersion is unset", async () => { + spawnAndCollectMock.mockResolvedValueOnce({ + stdout: "acpx 9.9.9\n", + stderr: "", + code: 0, + error: null, + }); + + const result = await checkAcpxVersion({ + command: "/custom/acpx", + cwd: "/custom", + expectedVersion: undefined, + }); + + expect(result).toEqual({ + ok: true, + version: "9.9.9", + expectedVersion: undefined, + }); + }); + it("installs and verifies pinned acpx when precheck fails", async () => { spawnAndCollectMock .mockResolvedValueOnce({ @@ -85,7 +112,7 @@ describe("acpx ensure", () => { error: null, }); - await ensurePinnedAcpx({ + await ensureAcpx({ command: "/plugin/node_modules/.bin/acpx", pluginRoot: "/plugin", expectedVersion: ACPX_PINNED_VERSION, @@ -115,11 +142,52 @@ describe("acpx ensure", () => { }); await expect( - ensurePinnedAcpx({ + ensureAcpx({ command: "/plugin/node_modules/.bin/acpx", pluginRoot: "/plugin", expectedVersion: ACPX_PINNED_VERSION, }), ).rejects.toThrow("failed to install plugin-local acpx"); }); + + it("skips install path when allowInstall=false", async () => { + spawnAndCollectMock.mockResolvedValueOnce({ + stdout: "", + stderr: "", + code: 0, + error: new Error("not found"), + }); + resolveSpawnFailureMock.mockReturnValue("missing-command"); + + await expect( + ensureAcpx({ + command: "/custom/acpx", + pluginRoot: "/plugin", + expectedVersion: undefined, + allowInstall: false, + }), + ).rejects.toThrow("acpx command not found at /custom/acpx"); + + expect(spawnAndCollectMock).toHaveBeenCalledTimes(1); + }); + + it("uses expectedVersion for install command metadata", async () => { + spawnAndCollectMock.mockResolvedValueOnce({ + stdout: "acpx 0.0.9\n", + stderr: "", + code: 0, + error: null, + }); + + const result = await checkAcpxVersion({ + command: "/plugin/node_modules/.bin/acpx", + cwd: "/plugin", + expectedVersion: "0.2.0", + }); + + expect(result).toMatchObject({ + ok: false, + installCommand: buildAcpxLocalInstallCommand("0.2.0"), + }); + }); }); diff --git a/extensions/acpx/src/ensure.ts b/extensions/acpx/src/ensure.ts index 6bb015587ae..dd661152fd4 100644 --- a/extensions/acpx/src/ensure.ts +++ b/extensions/acpx/src/ensure.ts @@ -1,5 +1,5 @@ import type { PluginLogger } from "openclaw/plugin-sdk"; -import { ACPX_LOCAL_INSTALL_COMMAND, ACPX_PINNED_VERSION, ACPX_PLUGIN_ROOT } from "./config.js"; +import { ACPX_PINNED_VERSION, ACPX_PLUGIN_ROOT, buildAcpxLocalInstallCommand } from "./config.js"; import { resolveSpawnFailure, spawnAndCollect } from "./runtime-internals/process.js"; const SEMVER_PATTERN = /\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\b/; @@ -8,13 +8,13 @@ export type AcpxVersionCheckResult = | { ok: true; version: string; - expectedVersion: string; + expectedVersion?: string; } | { ok: false; reason: "missing-command" | "missing-version" | "version-mismatch" | "execution-failed"; message: string; - expectedVersion: string; + expectedVersion?: string; installCommand: string; installedVersion?: string; }; @@ -25,12 +25,13 @@ function extractVersion(stdout: string, stderr: string): string | null { return match?.[0] ?? null; } -export async function checkPinnedAcpxVersion(params: { +export async function checkAcpxVersion(params: { command: string; cwd?: string; expectedVersion?: string; }): Promise { - const expectedVersion = params.expectedVersion ?? ACPX_PINNED_VERSION; + const expectedVersion = params.expectedVersion?.trim() || undefined; + const installCommand = buildAcpxLocalInstallCommand(expectedVersion ?? ACPX_PINNED_VERSION); const cwd = params.cwd ?? ACPX_PLUGIN_ROOT; const result = await spawnAndCollect({ command: params.command, @@ -46,7 +47,7 @@ export async function checkPinnedAcpxVersion(params: { reason: "missing-command", message: `acpx command not found at ${params.command}`, expectedVersion, - installCommand: ACPX_LOCAL_INSTALL_COMMAND, + installCommand, }; } return { @@ -54,7 +55,7 @@ export async function checkPinnedAcpxVersion(params: { reason: "execution-failed", message: result.error.message, expectedVersion, - installCommand: ACPX_LOCAL_INSTALL_COMMAND, + installCommand, }; } @@ -65,7 +66,7 @@ export async function checkPinnedAcpxVersion(params: { reason: "execution-failed", message: stderr || `acpx --version failed with code ${result.code ?? "unknown"}`, expectedVersion, - installCommand: ACPX_LOCAL_INSTALL_COMMAND, + installCommand, }; } @@ -76,17 +77,17 @@ export async function checkPinnedAcpxVersion(params: { reason: "missing-version", message: "acpx --version output did not include a parseable version", expectedVersion, - installCommand: ACPX_LOCAL_INSTALL_COMMAND, + installCommand, }; } - if (installedVersion !== expectedVersion) { + if (expectedVersion && installedVersion !== expectedVersion) { return { ok: false, reason: "version-mismatch", message: `acpx version mismatch: found ${installedVersion}, expected ${expectedVersion}`, expectedVersion, - installCommand: ACPX_LOCAL_INSTALL_COMMAND, + installCommand, installedVersion, }; } @@ -100,11 +101,12 @@ export async function checkPinnedAcpxVersion(params: { let pendingEnsure: Promise | null = null; -export async function ensurePinnedAcpx(params: { +export async function ensureAcpx(params: { command: string; logger?: PluginLogger; pluginRoot?: string; expectedVersion?: string; + allowInstall?: boolean; }): Promise { if (pendingEnsure) { return await pendingEnsure; @@ -112,9 +114,11 @@ export async function ensurePinnedAcpx(params: { pendingEnsure = (async () => { const pluginRoot = params.pluginRoot ?? ACPX_PLUGIN_ROOT; - const expectedVersion = params.expectedVersion ?? ACPX_PINNED_VERSION; + const expectedVersion = params.expectedVersion?.trim() || undefined; + const installVersion = expectedVersion ?? ACPX_PINNED_VERSION; + const allowInstall = params.allowInstall ?? true; - const precheck = await checkPinnedAcpxVersion({ + const precheck = await checkAcpxVersion({ command: params.command, cwd: pluginRoot, expectedVersion, @@ -122,6 +126,9 @@ export async function ensurePinnedAcpx(params: { if (precheck.ok) { return; } + if (!allowInstall) { + throw new Error(precheck.message); + } params.logger?.warn( `acpx local binary unavailable or mismatched (${precheck.message}); running plugin-local install`, @@ -129,7 +136,7 @@ export async function ensurePinnedAcpx(params: { const install = await spawnAndCollect({ command: "npm", - args: ["install", "--omit=dev", "--no-save", `acpx@${expectedVersion}`], + args: ["install", "--omit=dev", "--no-save", `acpx@${installVersion}`], cwd: pluginRoot, }); @@ -148,7 +155,7 @@ export async function ensurePinnedAcpx(params: { throw new Error(`failed to install plugin-local acpx: ${detail}`); } - const postcheck = await checkPinnedAcpxVersion({ + const postcheck = await checkAcpxVersion({ command: params.command, cwd: pluginRoot, expectedVersion, diff --git a/extensions/acpx/src/runtime.test.ts b/extensions/acpx/src/runtime.test.ts index d5e4fd275c7..f9096bbc73e 100644 --- a/extensions/acpx/src/runtime.test.ts +++ b/extensions/acpx/src/runtime.test.ts @@ -265,6 +265,8 @@ async function createMockRuntime(params?: { const config: ResolvedAcpxPluginConfig = { command: scriptPath, + allowPluginLocalInstall: false, + installCommand: "n/a", cwd: dir, permissionMode: params?.permissionMode ?? "approve-all", nonInteractivePermissions: "fail", @@ -581,6 +583,8 @@ describe("AcpxRuntime", () => { const runtime = new AcpxRuntime( { command: "/definitely/missing/acpx", + allowPluginLocalInstall: false, + installCommand: "n/a", cwd: process.cwd(), permissionMode: "approve-reads", nonInteractivePermissions: "fail", @@ -603,6 +607,8 @@ describe("AcpxRuntime", () => { const runtime = new AcpxRuntime( { command: "/definitely/missing/acpx", + allowPluginLocalInstall: false, + installCommand: "n/a", cwd: process.cwd(), permissionMode: "approve-reads", nonInteractivePermissions: "fail", diff --git a/extensions/acpx/src/runtime.ts b/extensions/acpx/src/runtime.ts index a5273c7e0f2..1256c8903c3 100644 --- a/extensions/acpx/src/runtime.ts +++ b/extensions/acpx/src/runtime.ts @@ -12,12 +12,8 @@ import type { PluginLogger, } from "openclaw/plugin-sdk"; import { AcpRuntimeError } from "openclaw/plugin-sdk"; -import { - ACPX_LOCAL_INSTALL_COMMAND, - ACPX_PINNED_VERSION, - type ResolvedAcpxPluginConfig, -} from "./config.js"; -import { checkPinnedAcpxVersion } from "./ensure.js"; +import { type ResolvedAcpxPluginConfig } from "./config.js"; +import { checkAcpxVersion } from "./ensure.js"; import { parseJsonLines, parsePromptEventLine, @@ -121,10 +117,10 @@ export class AcpxRuntime implements AcpRuntime { } async probeAvailability(): Promise { - const versionCheck = await checkPinnedAcpxVersion({ + const versionCheck = await checkAcpxVersion({ command: this.config.command, cwd: this.config.cwd, - expectedVersion: ACPX_PINNED_VERSION, + expectedVersion: this.config.expectedVersion, }); if (!versionCheck.ok) { this.healthy = false; @@ -376,15 +372,15 @@ export class AcpxRuntime implements AcpRuntime { } async doctor(): Promise { - const versionCheck = await checkPinnedAcpxVersion({ + const versionCheck = await checkAcpxVersion({ command: this.config.command, cwd: this.config.cwd, - expectedVersion: ACPX_PINNED_VERSION, + expectedVersion: this.config.expectedVersion, }); if (!versionCheck.ok) { this.healthy = false; const details = [ - `expected=${versionCheck.expectedVersion}`, + versionCheck.expectedVersion ? `expected=${versionCheck.expectedVersion}` : null, versionCheck.installedVersion ? `installed=${versionCheck.installedVersion}` : null, ].filter((detail): detail is string => Boolean(detail)); return { @@ -410,7 +406,7 @@ export class AcpxRuntime implements AcpRuntime { ok: false, code: "ACP_BACKEND_UNAVAILABLE", message: `acpx command not found: ${this.config.command}`, - installCommand: ACPX_LOCAL_INSTALL_COMMAND, + installCommand: this.config.installCommand, }; } if (spawnFailure === "missing-cwd") { @@ -440,7 +436,7 @@ export class AcpxRuntime implements AcpRuntime { this.healthy = true; return { ok: true, - message: `acpx command available (${this.config.command}, version ${versionCheck.version})`, + message: `acpx command available (${this.config.command}, version ${versionCheck.version}${this.config.expectedVersion ? `, expected ${this.config.expectedVersion}` : ""})`, }; } catch (error) { this.healthy = false; diff --git a/extensions/acpx/src/service.test.ts b/extensions/acpx/src/service.test.ts index 30fc9fa7205..125255b2455 100644 --- a/extensions/acpx/src/service.test.ts +++ b/extensions/acpx/src/service.test.ts @@ -9,12 +9,12 @@ import { import { ACPX_BUNDLED_BIN } from "./config.js"; import { createAcpxRuntimeService } from "./service.js"; -const { ensurePinnedAcpxSpy } = vi.hoisted(() => ({ - ensurePinnedAcpxSpy: vi.fn(async () => {}), +const { ensureAcpxSpy } = vi.hoisted(() => ({ + ensureAcpxSpy: vi.fn(async () => {}), })); vi.mock("./ensure.js", () => ({ - ensurePinnedAcpx: ensurePinnedAcpxSpy, + ensureAcpx: ensureAcpxSpy, })); type RuntimeStub = AcpRuntime & { @@ -73,8 +73,8 @@ function createServiceContext( describe("createAcpxRuntimeService", () => { beforeEach(() => { __testing.resetAcpRuntimeBackendsForTests(); - ensurePinnedAcpxSpy.mockReset(); - ensurePinnedAcpxSpy.mockImplementation(async () => {}); + ensureAcpxSpy.mockReset(); + ensureAcpxSpy.mockImplementation(async () => {}); }); it("registers and unregisters the acpx backend", async () => { @@ -88,7 +88,7 @@ describe("createAcpxRuntimeService", () => { expect(getAcpRuntimeBackend("acpx")?.runtime).toBe(runtime); await vi.waitFor(() => { - expect(ensurePinnedAcpxSpy).toHaveBeenCalledOnce(); + expect(ensureAcpxSpy).toHaveBeenCalledOnce(); expect(probeAvailabilitySpy).toHaveBeenCalledOnce(); }); @@ -132,6 +132,8 @@ describe("createAcpxRuntimeService", () => { queueOwnerTtlSeconds: 0.25, pluginConfig: expect.objectContaining({ command: ACPX_BUNDLED_BIN, + expectedVersion: "0.1.13", + allowPluginLocalInstall: true, }), }), ); @@ -156,7 +158,7 @@ describe("createAcpxRuntimeService", () => { it("does not block startup while acpx ensure runs", async () => { const { runtime } = createRuntimeStub(true); - ensurePinnedAcpxSpy.mockImplementation(() => new Promise(() => {})); + ensureAcpxSpy.mockImplementation(() => new Promise(() => {})); const service = createAcpxRuntimeService({ runtimeFactory: () => runtime, }); diff --git a/extensions/acpx/src/service.ts b/extensions/acpx/src/service.ts index 65768d00ce8..9ad3279675f 100644 --- a/extensions/acpx/src/service.ts +++ b/extensions/acpx/src/service.ts @@ -5,12 +5,8 @@ import type { PluginLogger, } from "openclaw/plugin-sdk"; import { registerAcpRuntimeBackend, unregisterAcpRuntimeBackend } from "openclaw/plugin-sdk"; -import { - ACPX_PINNED_VERSION, - resolveAcpxPluginConfig, - type ResolvedAcpxPluginConfig, -} from "./config.js"; -import { ensurePinnedAcpx } from "./ensure.js"; +import { resolveAcpxPluginConfig, type ResolvedAcpxPluginConfig } from "./config.js"; +import { ensureAcpx } from "./ensure.js"; import { ACPX_BACKEND_ID, AcpxRuntime } from "./runtime.js"; type AcpxRuntimeLike = AcpRuntime & { @@ -61,18 +57,21 @@ export function createAcpxRuntimeService( runtime, healthy: () => runtime?.isHealthy() ?? false, }); + const expectedVersionLabel = pluginConfig.expectedVersion ?? "any"; + const installLabel = pluginConfig.allowPluginLocalInstall ? "enabled" : "disabled"; ctx.logger.info( - `acpx runtime backend registered (command: ${pluginConfig.command}, pinned: ${ACPX_PINNED_VERSION})`, + `acpx runtime backend registered (command: ${pluginConfig.command}, expectedVersion: ${expectedVersionLabel}, pluginLocalInstall: ${installLabel})`, ); lifecycleRevision += 1; const currentRevision = lifecycleRevision; void (async () => { try { - await ensurePinnedAcpx({ + await ensureAcpx({ command: pluginConfig.command, logger: ctx.logger, - expectedVersion: ACPX_PINNED_VERSION, + expectedVersion: pluginConfig.expectedVersion, + allowInstall: pluginConfig.allowPluginLocalInstall, }); if (currentRevision !== lifecycleRevision) { return;