diff --git a/extensions/acpx/openclaw.plugin.json b/extensions/acpx/openclaw.plugin.json index 33a0096cd13..1941d3d41c8 100644 --- a/extensions/acpx/openclaw.plugin.json +++ b/extensions/acpx/openclaw.plugin.json @@ -41,7 +41,7 @@ }, "expectedVersion": { "label": "Expected acpx Version", - "help": "Exact version to enforce (for example 0.1.14) or \"any\" to skip strict version matching." + "help": "Exact version to enforce (for example 0.1.15) or \"any\" to skip strict version matching." }, "cwd": { "label": "Default Working Directory", diff --git a/extensions/acpx/package.json b/extensions/acpx/package.json index 953f9883214..935b45d2f12 100644 --- a/extensions/acpx/package.json +++ b/extensions/acpx/package.json @@ -4,7 +4,7 @@ "description": "OpenClaw ACP runtime backend via acpx", "type": "module", "dependencies": { - "acpx": "0.1.14" + "acpx": "0.1.15" }, "openclaw": { "extensions": [ diff --git a/extensions/acpx/src/config.ts b/extensions/acpx/src/config.ts index 538ea3fa400..0025b73caaa 100644 --- a/extensions/acpx/src/config.ts +++ b/extensions/acpx/src/config.ts @@ -8,7 +8,7 @@ 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.14"; +export const ACPX_PINNED_VERSION = "0.1.15"; 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)), ".."); diff --git a/extensions/acpx/src/ensure.test.ts b/extensions/acpx/src/ensure.test.ts index 7690eba6a3b..3bc6f666031 100644 --- a/extensions/acpx/src/ensure.test.ts +++ b/extensions/acpx/src/ensure.test.ts @@ -1,4 +1,7 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ACPX_LOCAL_INSTALL_COMMAND, ACPX_PINNED_VERSION, @@ -20,12 +23,37 @@ vi.mock("./runtime-internals/process.js", () => ({ import { checkAcpxVersion, ensureAcpx } from "./ensure.js"; describe("acpx ensure", () => { + const tempDirs: string[] = []; + beforeEach(() => { resolveSpawnFailureMock.mockReset(); resolveSpawnFailureMock.mockReturnValue(null); spawnAndCollectMock.mockReset(); }); + function makeTempAcpxInstall(version: string): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "acpx-ensure-test-")); + tempDirs.push(root); + const packageRoot = path.join(root, "node_modules", "acpx"); + fs.mkdirSync(path.join(packageRoot, "dist"), { recursive: true }); + fs.mkdirSync(path.join(root, "node_modules", ".bin"), { recursive: true }); + fs.writeFileSync( + path.join(packageRoot, "package.json"), + JSON.stringify({ name: "acpx", version }, null, 2), + "utf8", + ); + fs.writeFileSync(path.join(packageRoot, "dist", "cli.js"), "#!/usr/bin/env node\n", "utf8"); + const binPath = path.join(root, "node_modules", ".bin", "acpx"); + fs.symlinkSync(path.join(packageRoot, "dist", "cli.js"), binPath); + return binPath; + } + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + it("accepts the pinned acpx version", async () => { spawnAndCollectMock.mockResolvedValueOnce({ stdout: `acpx ${ACPX_PINNED_VERSION}\n`, @@ -75,6 +103,28 @@ describe("acpx ensure", () => { }); }); + it("falls back to package.json version when --version is unsupported", async () => { + const command = makeTempAcpxInstall(ACPX_PINNED_VERSION); + spawnAndCollectMock.mockResolvedValueOnce({ + stdout: "", + stderr: "error: unknown option '--version'", + code: 2, + error: null, + }); + + const result = await checkAcpxVersion({ + command, + cwd: path.dirname(path.dirname(command)), + expectedVersion: ACPX_PINNED_VERSION, + }); + + expect(result).toEqual({ + ok: true, + version: ACPX_PINNED_VERSION, + expectedVersion: ACPX_PINNED_VERSION, + }); + }); + it("accepts command availability when expectedVersion is unset", async () => { spawnAndCollectMock.mockResolvedValueOnce({ stdout: "Usage: acpx [options]\n", diff --git a/extensions/acpx/src/ensure.ts b/extensions/acpx/src/ensure.ts index 8238e85a9f4..4fcdf6aee4e 100644 --- a/extensions/acpx/src/ensure.ts +++ b/extensions/acpx/src/ensure.ts @@ -1,3 +1,5 @@ +import fs from "node:fs"; +import path from "node:path"; import type { PluginLogger } from "openclaw/plugin-sdk"; import { ACPX_PINNED_VERSION, ACPX_PLUGIN_ROOT, buildAcpxLocalInstallCommand } from "./config.js"; import { resolveSpawnFailure, spawnAndCollect } from "./runtime-internals/process.js"; @@ -29,6 +31,47 @@ function isExpectedVersionConfigured(value: string | undefined): value is string return typeof value === "string" && value.trim().length > 0; } +function supportsPathResolution(command: string): boolean { + return path.isAbsolute(command) || command.includes("/") || command.includes("\\"); +} + +function isUnsupportedVersionProbe(stdout: string, stderr: string): boolean { + const combined = `${stdout}\n${stderr}`.toLowerCase(); + return combined.includes("unknown option") && combined.includes("--version"); +} + +function resolveVersionFromPackage(command: string, cwd: string): string | null { + if (!supportsPathResolution(command)) { + return null; + } + const commandPath = path.isAbsolute(command) ? command : path.resolve(cwd, command); + let current: string; + try { + current = path.dirname(fs.realpathSync(commandPath)); + } catch { + return null; + } + while (true) { + const packageJsonPath = path.join(current, "package.json"); + try { + const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { + name?: unknown; + version?: unknown; + }; + if (parsed.name === "acpx" && typeof parsed.version === "string" && parsed.version.trim()) { + return parsed.version.trim(); + } + } catch { + // no-op; continue walking up + } + const parent = path.dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +} + export async function checkAcpxVersion(params: { command: string; cwd?: string; @@ -66,6 +109,26 @@ export async function checkAcpxVersion(params: { } if ((result.code ?? 0) !== 0) { + if (hasExpectedVersion && isUnsupportedVersionProbe(result.stdout, result.stderr)) { + const installedVersion = resolveVersionFromPackage(params.command, cwd); + if (installedVersion) { + if (expectedVersion && installedVersion !== expectedVersion) { + return { + ok: false, + reason: "version-mismatch", + message: `acpx version mismatch: found ${installedVersion}, expected ${expectedVersion}`, + expectedVersion, + installCommand, + installedVersion, + }; + } + return { + ok: true, + version: installedVersion, + expectedVersion, + }; + } + } const stderr = result.stderr.trim(); return { ok: false, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a5aad617c4..a33a9098d1f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -264,8 +264,8 @@ importers: extensions/acpx: dependencies: acpx: - specifier: 0.1.14 - version: 0.1.14(zod@4.3.6) + specifier: 0.1.15 + version: 0.1.15(zod@4.3.6) extensions/bluebubbles: {} @@ -3131,8 +3131,8 @@ packages: link-preview-js: optional: true - '@whiskeysockets/libsignal-node@git+https://github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67': - resolution: {commit: 1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67, repo: https://github.com/whiskeysockets/libsignal-node.git, type: git} + '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67': + resolution: {tarball: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67} version: 2.0.1 abbrev@1.1.1: @@ -3160,8 +3160,8 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - acpx@0.1.14: - resolution: {integrity: sha512-kq1tU7VCOLW3dIK77PpGoJPMsIqmnOSiQJGsWfWiOYgTXYIsbNtP04ilsaobgDd/MUgjo9ttXD1abziQ3OH5Pg==} + acpx@0.1.15: + resolution: {integrity: sha512-1r+tmPT9Oe2Ulv5b4r7O2hCCq5CHVru/H2tcPeTpZek9jR1zBQoBfZ/RcK+9sC9/mnDvWYO5R7Iae64v2LMO+A==} engines: {node: '>=18'} hasBin: true @@ -9007,7 +9007,7 @@ snapshots: '@cacheable/node-cache': 1.7.6 '@hapi/boom': 9.1.4 async-mutex: 0.5.0 - libsignal: '@whiskeysockets/libsignal-node@git+https://github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67' + libsignal: '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67' lru-cache: 11.2.6 music-metadata: 11.12.1 p-queue: 9.1.0 @@ -9022,7 +9022,7 @@ snapshots: - supports-color - utf-8-validate - '@whiskeysockets/libsignal-node@git+https://github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67': + '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67': dependencies: curve25519-js: 0.0.4 protobufjs: 6.8.8 @@ -9050,7 +9050,7 @@ snapshots: acorn@8.16.0: {} - acpx@0.1.14(zod@4.3.6): + acpx@0.1.15(zod@4.3.6): dependencies: '@agentclientprotocol/sdk': 0.14.1(zod@4.3.6) commander: 13.1.0