Include full contents of all nested repositories
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
19
openclaw/extensions/acpx/index.ts
Normal file
19
openclaw/extensions/acpx/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { createAcpxPluginConfigSchema } from "./src/config.js";
|
||||
import { createAcpxRuntimeService } from "./src/service.js";
|
||||
|
||||
const plugin = {
|
||||
id: "acpx",
|
||||
name: "ACPX Runtime",
|
||||
description: "ACP runtime backend powered by the acpx CLI.",
|
||||
configSchema: createAcpxPluginConfigSchema(),
|
||||
register(api: OpenClawPluginApi) {
|
||||
api.registerService(
|
||||
createAcpxRuntimeService({
|
||||
pluginConfig: api.pluginConfig,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
55
openclaw/extensions/acpx/openclaw.plugin.json
Normal file
55
openclaw/extensions/acpx/openclaw.plugin.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"id": "acpx",
|
||||
"name": "ACPX Runtime",
|
||||
"description": "ACP runtime backend powered by a pinned plugin-local acpx CLI.",
|
||||
"skills": ["./skills"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"cwd": {
|
||||
"type": "string"
|
||||
},
|
||||
"permissionMode": {
|
||||
"type": "string",
|
||||
"enum": ["approve-all", "approve-reads", "deny-all"]
|
||||
},
|
||||
"nonInteractivePermissions": {
|
||||
"type": "string",
|
||||
"enum": ["deny", "fail"]
|
||||
},
|
||||
"timeoutSeconds": {
|
||||
"type": "number",
|
||||
"minimum": 0.001
|
||||
},
|
||||
"queueOwnerTtlSeconds": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"uiHints": {
|
||||
"cwd": {
|
||||
"label": "Default Working Directory",
|
||||
"help": "Default cwd for ACP session operations when not set per session."
|
||||
},
|
||||
"permissionMode": {
|
||||
"label": "Permission Mode",
|
||||
"help": "Default acpx permission policy for runtime prompts."
|
||||
},
|
||||
"nonInteractivePermissions": {
|
||||
"label": "Non-Interactive Permission Policy",
|
||||
"help": "acpx policy when interactive permission prompts are unavailable."
|
||||
},
|
||||
"timeoutSeconds": {
|
||||
"label": "Prompt Timeout Seconds",
|
||||
"help": "Optional acpx timeout for each runtime turn.",
|
||||
"advanced": true
|
||||
},
|
||||
"queueOwnerTtlSeconds": {
|
||||
"label": "Queue Owner TTL Seconds",
|
||||
"help": "Idle queue-owner TTL for acpx prompt turns. Keep this short in OpenClaw to avoid delayed completion after each turn.",
|
||||
"advanced": true
|
||||
}
|
||||
}
|
||||
}
|
||||
14
openclaw/extensions/acpx/package.json
Normal file
14
openclaw/extensions/acpx/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@openclaw/acpx",
|
||||
"version": "2026.2.26",
|
||||
"description": "OpenClaw ACP runtime backend via acpx",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"acpx": "^0.1.13"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
209
openclaw/extensions/acpx/skills/acp-router/SKILL.md
Normal file
209
openclaw/extensions/acpx/skills/acp-router/SKILL.md
Normal file
@@ -0,0 +1,209 @@
|
||||
---
|
||||
name: acp-router
|
||||
description: Route plain-language requests for Pi, Claude Code, Codex, OpenCode, Gemini CLI, or ACP harness work into either OpenClaw ACP runtime sessions or direct acpx-driven sessions ("telephone game" flow).
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# ACP Harness Router
|
||||
|
||||
When user intent is "run this in Pi/Claude Code/Codex/OpenCode/Gemini (ACP harness)", do not use subagent runtime or PTY scraping. Route through ACP-aware flows.
|
||||
|
||||
## Intent detection
|
||||
|
||||
Trigger this skill when the user asks OpenClaw to:
|
||||
|
||||
- run something in Pi / Claude Code / Codex / OpenCode / Gemini
|
||||
- continue existing harness work
|
||||
- relay instructions to an external coding harness
|
||||
- keep an external harness conversation in a thread-like conversation
|
||||
|
||||
## Mode selection
|
||||
|
||||
Choose one of these paths:
|
||||
|
||||
1. OpenClaw ACP runtime path (default): use `sessions_spawn` / ACP runtime tools.
|
||||
2. Direct `acpx` path (telephone game): use `acpx` CLI through `exec` to drive the harness session directly.
|
||||
|
||||
Use direct `acpx` when one of these is true:
|
||||
|
||||
- user explicitly asks for direct `acpx` driving
|
||||
- ACP runtime/plugin path is unavailable or unhealthy
|
||||
- the task is "just relay prompts to harness" and no OpenClaw ACP lifecycle features are needed
|
||||
|
||||
Do not use:
|
||||
|
||||
- `subagents` runtime for harness control
|
||||
- `/acp` command delegation as a requirement for the user
|
||||
- PTY scraping of pi/claude/codex/opencode/gemini CLIs when `acpx` is available
|
||||
|
||||
## AgentId mapping
|
||||
|
||||
Use these defaults when user names a harness directly:
|
||||
|
||||
- "pi" -> `agentId: "pi"`
|
||||
- "claude" or "claude code" -> `agentId: "claude"`
|
||||
- "codex" -> `agentId: "codex"`
|
||||
- "opencode" -> `agentId: "opencode"`
|
||||
- "gemini" or "gemini cli" -> `agentId: "gemini"`
|
||||
|
||||
These defaults match current acpx built-in aliases.
|
||||
|
||||
If policy rejects the chosen id, report the policy error clearly and ask for the allowed ACP agent id.
|
||||
|
||||
## OpenClaw ACP runtime path
|
||||
|
||||
Required behavior:
|
||||
|
||||
1. Use `sessions_spawn` with:
|
||||
- `runtime: "acp"`
|
||||
- `thread: true`
|
||||
- `mode: "session"` (unless user explicitly wants one-shot)
|
||||
2. Put requested work in `task` so the ACP session gets it immediately.
|
||||
3. Set `agentId` explicitly unless ACP default agent is known.
|
||||
4. Do not ask user to run slash commands or CLI when this path works directly.
|
||||
|
||||
Example:
|
||||
|
||||
User: "spawn a test codex session in thread and tell it to say hi"
|
||||
|
||||
Call:
|
||||
|
||||
```json
|
||||
{
|
||||
"task": "Say hi.",
|
||||
"runtime": "acp",
|
||||
"agentId": "codex",
|
||||
"thread": true,
|
||||
"mode": "session"
|
||||
}
|
||||
```
|
||||
|
||||
## Thread spawn recovery policy
|
||||
|
||||
When the user asks to start a coding harness in a thread (for example "start a codex/claude/pi thread"), treat that as an ACP runtime request and try to satisfy it end-to-end.
|
||||
|
||||
Required behavior when ACP backend is unavailable:
|
||||
|
||||
1. Do not immediately ask the user to pick an alternate path.
|
||||
2. First attempt automatic local repair:
|
||||
- ensure plugin-local pinned acpx is installed in `extensions/acpx`
|
||||
- verify `${ACPX_CMD} --version`
|
||||
3. After reinstall/repair, restart the gateway and explicitly offer to run that restart for the user.
|
||||
4. Retry ACP thread spawn once after repair.
|
||||
5. Only if repair+retry fails, report the concrete error and then offer fallback options.
|
||||
|
||||
When offering fallback, keep ACP first:
|
||||
|
||||
- Option 1: retry ACP spawn after showing exact failing step
|
||||
- Option 2: direct acpx telephone-game flow
|
||||
|
||||
Do not default to subagent runtime for these requests.
|
||||
|
||||
## ACPX install and version policy (direct acpx path)
|
||||
|
||||
For this repo, direct `acpx` calls must follow the same pinned policy as the `@openclaw/acpx` extension.
|
||||
|
||||
1. Prefer plugin-local binary, not global PATH:
|
||||
- `./extensions/acpx/node_modules/.bin/acpx`
|
||||
2. Resolve pinned version from extension dependency:
|
||||
- `node -e "console.log(require('./extensions/acpx/package.json').dependencies.acpx)"`
|
||||
3. If binary is missing or version mismatched, install plugin-local pinned version:
|
||||
- `cd extensions/acpx && npm install --omit=dev --no-save acpx@<pinnedVersion>`
|
||||
4. Verify before use:
|
||||
- `./extensions/acpx/node_modules/.bin/acpx --version`
|
||||
5. If install/repair changed ACPX artifacts, restart the gateway and offer to run the restart.
|
||||
6. Do not run `npm install -g acpx` unless the user explicitly asks for global install.
|
||||
|
||||
Set and reuse:
|
||||
|
||||
```bash
|
||||
ACPX_CMD="./extensions/acpx/node_modules/.bin/acpx"
|
||||
```
|
||||
|
||||
## Direct acpx path ("telephone game")
|
||||
|
||||
Use this path to drive harness sessions without `/acp` or subagent runtime.
|
||||
|
||||
### Rules
|
||||
|
||||
1. Use `exec` commands that call `${ACPX_CMD}`.
|
||||
2. Reuse a stable session name per conversation so follow-up prompts stay in the same harness context.
|
||||
3. Prefer `--format quiet` for clean assistant text to relay back to user.
|
||||
4. Use `exec` (one-shot) only when the user wants one-shot behavior.
|
||||
5. Keep working directory explicit (`--cwd`) when task scope depends on repo context.
|
||||
|
||||
### Session naming
|
||||
|
||||
Use a deterministic name, for example:
|
||||
|
||||
- `oc-<harness>-<conversationId>`
|
||||
|
||||
Where `conversationId` is thread id when available, otherwise channel/conversation id.
|
||||
|
||||
### Command templates
|
||||
|
||||
Persistent session (create if missing, then prompt):
|
||||
|
||||
```bash
|
||||
${ACPX_CMD} codex sessions show oc-codex-<conversationId> \
|
||||
|| ${ACPX_CMD} codex sessions new --name oc-codex-<conversationId>
|
||||
|
||||
${ACPX_CMD} codex -s oc-codex-<conversationId> --cwd <workspacePath> --format quiet "<prompt>"
|
||||
```
|
||||
|
||||
One-shot:
|
||||
|
||||
```bash
|
||||
${ACPX_CMD} codex exec --cwd <workspacePath> --format quiet "<prompt>"
|
||||
```
|
||||
|
||||
Cancel in-flight turn:
|
||||
|
||||
```bash
|
||||
${ACPX_CMD} codex cancel -s oc-codex-<conversationId>
|
||||
```
|
||||
|
||||
Close session:
|
||||
|
||||
```bash
|
||||
${ACPX_CMD} codex sessions close oc-codex-<conversationId>
|
||||
```
|
||||
|
||||
### Harness aliases in acpx
|
||||
|
||||
- `pi`
|
||||
- `claude`
|
||||
- `codex`
|
||||
- `opencode`
|
||||
- `gemini`
|
||||
|
||||
### Built-in adapter commands in acpx
|
||||
|
||||
Defaults are:
|
||||
|
||||
- `pi -> npx pi-acp`
|
||||
- `claude -> npx -y @zed-industries/claude-agent-acp`
|
||||
- `codex -> npx @zed-industries/codex-acp`
|
||||
- `opencode -> npx -y opencode-ai acp`
|
||||
- `gemini -> gemini`
|
||||
|
||||
If `~/.acpx/config.json` overrides `agents`, those overrides replace defaults.
|
||||
|
||||
### Failure handling
|
||||
|
||||
- `acpx: command not found`:
|
||||
- for thread-spawn ACP requests, install plugin-local pinned acpx in `extensions/acpx` immediately
|
||||
- restart gateway after install and offer to run the restart automatically
|
||||
- then retry once
|
||||
- do not ask for install permission first unless policy explicitly requires it
|
||||
- do not install global `acpx` unless explicitly requested
|
||||
- adapter command missing (for example `claude-agent-acp` not found):
|
||||
- for thread-spawn ACP requests, first restore built-in defaults by removing broken `~/.acpx/config.json` agent overrides
|
||||
- then retry once before offering fallback
|
||||
- if user wants binary-based overrides, install exactly the configured adapter binary
|
||||
- `NO_SESSION`: run `${ACPX_CMD} <agent> sessions new --name <sessionName>` then retry prompt.
|
||||
- queue busy: either wait for completion (default) or use `--no-wait` when async behavior is explicitly desired.
|
||||
|
||||
### Output relay
|
||||
|
||||
When relaying to user, return the final assistant text output from `acpx` command result. Avoid relaying raw local tool noise unless user asked for verbose logs.
|
||||
53
openclaw/extensions/acpx/src/config.test.ts
Normal file
53
openclaw/extensions/acpx/src/config.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ACPX_BUNDLED_BIN,
|
||||
createAcpxPluginConfigSchema,
|
||||
resolveAcpxPluginConfig,
|
||||
} from "./config.js";
|
||||
|
||||
describe("acpx plugin config parsing", () => {
|
||||
it("resolves a strict plugin-local acpx command", () => {
|
||||
const resolved = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
cwd: "/tmp/workspace",
|
||||
},
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
|
||||
expect(resolved.command).toBe(ACPX_BUNDLED_BIN);
|
||||
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("rejects commandArgs overrides", () => {
|
||||
expect(() =>
|
||||
resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
commandArgs: ["--foo"],
|
||||
},
|
||||
workspaceDir: "/tmp/workspace",
|
||||
}),
|
||||
).toThrow("unknown config key: commandArgs");
|
||||
});
|
||||
|
||||
it("schema rejects empty cwd", () => {
|
||||
const schema = createAcpxPluginConfigSchema();
|
||||
if (!schema.safeParse) {
|
||||
throw new Error("acpx config schema missing safeParse");
|
||||
}
|
||||
const parsed = schema.safeParse({ cwd: " " });
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
});
|
||||
196
openclaw/extensions/acpx/src/config.ts
Normal file
196
openclaw/extensions/acpx/src/config.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
|
||||
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.13";
|
||||
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 type AcpxPluginConfig = {
|
||||
cwd?: string;
|
||||
permissionMode?: AcpxPermissionMode;
|
||||
nonInteractivePermissions?: AcpxNonInteractivePermissionPolicy;
|
||||
timeoutSeconds?: number;
|
||||
queueOwnerTtlSeconds?: number;
|
||||
};
|
||||
|
||||
export type ResolvedAcpxPluginConfig = {
|
||||
command: string;
|
||||
cwd: string;
|
||||
permissionMode: AcpxPermissionMode;
|
||||
nonInteractivePermissions: AcpxNonInteractivePermissionPolicy;
|
||||
timeoutSeconds?: number;
|
||||
queueOwnerTtlSeconds: number;
|
||||
};
|
||||
|
||||
const DEFAULT_PERMISSION_MODE: AcpxPermissionMode = "approve-reads";
|
||||
const DEFAULT_NON_INTERACTIVE_POLICY: AcpxNonInteractivePermissionPolicy = "fail";
|
||||
const DEFAULT_QUEUE_OWNER_TTL_SECONDS = 0.1;
|
||||
|
||||
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 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([
|
||||
"cwd",
|
||||
"permissionMode",
|
||||
"nonInteractivePermissions",
|
||||
"timeoutSeconds",
|
||||
"queueOwnerTtlSeconds",
|
||||
]);
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
return { ok: false, message: `unknown config key: ${key}` };
|
||||
}
|
||||
}
|
||||
|
||||
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 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" };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
cwd: typeof cwd === "string" ? cwd.trim() : undefined,
|
||||
permissionMode: typeof permissionMode === "string" ? permissionMode : undefined,
|
||||
nonInteractivePermissions:
|
||||
typeof nonInteractivePermissions === "string" ? nonInteractivePermissions : undefined,
|
||||
timeoutSeconds: typeof timeoutSeconds === "number" ? timeoutSeconds : undefined,
|
||||
queueOwnerTtlSeconds:
|
||||
typeof queueOwnerTtlSeconds === "number" ? queueOwnerTtlSeconds : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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: {
|
||||
cwd: { type: "string" },
|
||||
permissionMode: {
|
||||
type: "string",
|
||||
enum: [...ACPX_PERMISSION_MODES],
|
||||
},
|
||||
nonInteractivePermissions: {
|
||||
type: "string",
|
||||
enum: [...ACPX_NON_INTERACTIVE_POLICIES],
|
||||
},
|
||||
timeoutSeconds: { type: "number", minimum: 0.001 },
|
||||
queueOwnerTtlSeconds: { type: "number", minimum: 0 },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
return {
|
||||
command: ACPX_BUNDLED_BIN,
|
||||
cwd,
|
||||
permissionMode: normalized.permissionMode ?? DEFAULT_PERMISSION_MODE,
|
||||
nonInteractivePermissions:
|
||||
normalized.nonInteractivePermissions ?? DEFAULT_NON_INTERACTIVE_POLICY,
|
||||
timeoutSeconds: normalized.timeoutSeconds,
|
||||
queueOwnerTtlSeconds: normalized.queueOwnerTtlSeconds ?? DEFAULT_QUEUE_OWNER_TTL_SECONDS,
|
||||
};
|
||||
}
|
||||
125
openclaw/extensions/acpx/src/ensure.test.ts
Normal file
125
openclaw/extensions/acpx/src/ensure.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ACPX_LOCAL_INSTALL_COMMAND, ACPX_PINNED_VERSION } from "./config.js";
|
||||
|
||||
const { resolveSpawnFailureMock, spawnAndCollectMock } = vi.hoisted(() => ({
|
||||
resolveSpawnFailureMock: vi.fn(() => null),
|
||||
spawnAndCollectMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./runtime-internals/process.js", () => ({
|
||||
resolveSpawnFailure: resolveSpawnFailureMock,
|
||||
spawnAndCollect: spawnAndCollectMock,
|
||||
}));
|
||||
|
||||
import { checkPinnedAcpxVersion, ensurePinnedAcpx } from "./ensure.js";
|
||||
|
||||
describe("acpx ensure", () => {
|
||||
beforeEach(() => {
|
||||
resolveSpawnFailureMock.mockReset();
|
||||
resolveSpawnFailureMock.mockReturnValue(null);
|
||||
spawnAndCollectMock.mockReset();
|
||||
});
|
||||
|
||||
it("accepts the pinned acpx version", async () => {
|
||||
spawnAndCollectMock.mockResolvedValueOnce({
|
||||
stdout: `acpx ${ACPX_PINNED_VERSION}\n`,
|
||||
stderr: "",
|
||||
code: 0,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const result = await checkPinnedAcpxVersion({
|
||||
command: "/plugin/node_modules/.bin/acpx",
|
||||
cwd: "/plugin",
|
||||
expectedVersion: ACPX_PINNED_VERSION,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
version: ACPX_PINNED_VERSION,
|
||||
expectedVersion: ACPX_PINNED_VERSION,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports version mismatch", async () => {
|
||||
spawnAndCollectMock.mockResolvedValueOnce({
|
||||
stdout: "acpx 0.0.9\n",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const result = await checkPinnedAcpxVersion({
|
||||
command: "/plugin/node_modules/.bin/acpx",
|
||||
cwd: "/plugin",
|
||||
expectedVersion: ACPX_PINNED_VERSION,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
reason: "version-mismatch",
|
||||
expectedVersion: ACPX_PINNED_VERSION,
|
||||
installedVersion: "0.0.9",
|
||||
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
|
||||
});
|
||||
});
|
||||
|
||||
it("installs and verifies pinned acpx when precheck fails", async () => {
|
||||
spawnAndCollectMock
|
||||
.mockResolvedValueOnce({
|
||||
stdout: "acpx 0.0.9\n",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
error: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: "added 1 package\n",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
error: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: `acpx ${ACPX_PINNED_VERSION}\n`,
|
||||
stderr: "",
|
||||
code: 0,
|
||||
error: null,
|
||||
});
|
||||
|
||||
await ensurePinnedAcpx({
|
||||
command: "/plugin/node_modules/.bin/acpx",
|
||||
pluginRoot: "/plugin",
|
||||
expectedVersion: ACPX_PINNED_VERSION,
|
||||
});
|
||||
|
||||
expect(spawnAndCollectMock).toHaveBeenCalledTimes(3);
|
||||
expect(spawnAndCollectMock.mock.calls[1]?.[0]).toMatchObject({
|
||||
command: "npm",
|
||||
args: ["install", "--omit=dev", "--no-save", `acpx@${ACPX_PINNED_VERSION}`],
|
||||
cwd: "/plugin",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails with actionable error when npm install fails", async () => {
|
||||
spawnAndCollectMock
|
||||
.mockResolvedValueOnce({
|
||||
stdout: "acpx 0.0.9\n",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
error: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: "",
|
||||
stderr: "network down",
|
||||
code: 1,
|
||||
error: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
ensurePinnedAcpx({
|
||||
command: "/plugin/node_modules/.bin/acpx",
|
||||
pluginRoot: "/plugin",
|
||||
expectedVersion: ACPX_PINNED_VERSION,
|
||||
}),
|
||||
).rejects.toThrow("failed to install plugin-local acpx");
|
||||
});
|
||||
});
|
||||
169
openclaw/extensions/acpx/src/ensure.ts
Normal file
169
openclaw/extensions/acpx/src/ensure.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import type { PluginLogger } from "openclaw/plugin-sdk";
|
||||
import { ACPX_LOCAL_INSTALL_COMMAND, ACPX_PINNED_VERSION, ACPX_PLUGIN_ROOT } from "./config.js";
|
||||
import { resolveSpawnFailure, spawnAndCollect } from "./runtime-internals/process.js";
|
||||
|
||||
const SEMVER_PATTERN = /\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\b/;
|
||||
|
||||
export type AcpxVersionCheckResult =
|
||||
| {
|
||||
ok: true;
|
||||
version: string;
|
||||
expectedVersion: string;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
reason: "missing-command" | "missing-version" | "version-mismatch" | "execution-failed";
|
||||
message: string;
|
||||
expectedVersion: string;
|
||||
installCommand: string;
|
||||
installedVersion?: string;
|
||||
};
|
||||
|
||||
function extractVersion(stdout: string, stderr: string): string | null {
|
||||
const combined = `${stdout}\n${stderr}`;
|
||||
const match = combined.match(SEMVER_PATTERN);
|
||||
return match?.[0] ?? null;
|
||||
}
|
||||
|
||||
export async function checkPinnedAcpxVersion(params: {
|
||||
command: string;
|
||||
cwd?: string;
|
||||
expectedVersion?: string;
|
||||
}): Promise<AcpxVersionCheckResult> {
|
||||
const expectedVersion = params.expectedVersion ?? ACPX_PINNED_VERSION;
|
||||
const cwd = params.cwd ?? ACPX_PLUGIN_ROOT;
|
||||
const result = await spawnAndCollect({
|
||||
command: params.command,
|
||||
args: ["--version"],
|
||||
cwd,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
const spawnFailure = resolveSpawnFailure(result.error, cwd);
|
||||
if (spawnFailure === "missing-command") {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "missing-command",
|
||||
message: `acpx command not found at ${params.command}`,
|
||||
expectedVersion,
|
||||
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
reason: "execution-failed",
|
||||
message: result.error.message,
|
||||
expectedVersion,
|
||||
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
|
||||
};
|
||||
}
|
||||
|
||||
if ((result.code ?? 0) !== 0) {
|
||||
const stderr = result.stderr.trim();
|
||||
return {
|
||||
ok: false,
|
||||
reason: "execution-failed",
|
||||
message: stderr || `acpx --version failed with code ${result.code ?? "unknown"}`,
|
||||
expectedVersion,
|
||||
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
|
||||
};
|
||||
}
|
||||
|
||||
const installedVersion = extractVersion(result.stdout, result.stderr);
|
||||
if (!installedVersion) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "missing-version",
|
||||
message: "acpx --version output did not include a parseable version",
|
||||
expectedVersion,
|
||||
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
|
||||
};
|
||||
}
|
||||
|
||||
if (installedVersion !== expectedVersion) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "version-mismatch",
|
||||
message: `acpx version mismatch: found ${installedVersion}, expected ${expectedVersion}`,
|
||||
expectedVersion,
|
||||
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
|
||||
installedVersion,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
version: installedVersion,
|
||||
expectedVersion,
|
||||
};
|
||||
}
|
||||
|
||||
let pendingEnsure: Promise<void> | null = null;
|
||||
|
||||
export async function ensurePinnedAcpx(params: {
|
||||
command: string;
|
||||
logger?: PluginLogger;
|
||||
pluginRoot?: string;
|
||||
expectedVersion?: string;
|
||||
}): Promise<void> {
|
||||
if (pendingEnsure) {
|
||||
return await pendingEnsure;
|
||||
}
|
||||
|
||||
pendingEnsure = (async () => {
|
||||
const pluginRoot = params.pluginRoot ?? ACPX_PLUGIN_ROOT;
|
||||
const expectedVersion = params.expectedVersion ?? ACPX_PINNED_VERSION;
|
||||
|
||||
const precheck = await checkPinnedAcpxVersion({
|
||||
command: params.command,
|
||||
cwd: pluginRoot,
|
||||
expectedVersion,
|
||||
});
|
||||
if (precheck.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
params.logger?.warn(
|
||||
`acpx local binary unavailable or mismatched (${precheck.message}); running plugin-local install`,
|
||||
);
|
||||
|
||||
const install = await spawnAndCollect({
|
||||
command: "npm",
|
||||
args: ["install", "--omit=dev", "--no-save", `acpx@${expectedVersion}`],
|
||||
cwd: pluginRoot,
|
||||
});
|
||||
|
||||
if (install.error) {
|
||||
const spawnFailure = resolveSpawnFailure(install.error, pluginRoot);
|
||||
if (spawnFailure === "missing-command") {
|
||||
throw new Error("npm is required to install plugin-local acpx but was not found on PATH");
|
||||
}
|
||||
throw new Error(`failed to install plugin-local acpx: ${install.error.message}`);
|
||||
}
|
||||
|
||||
if ((install.code ?? 0) !== 0) {
|
||||
const stderr = install.stderr.trim();
|
||||
const stdout = install.stdout.trim();
|
||||
const detail = stderr || stdout || `npm exited with code ${install.code ?? "unknown"}`;
|
||||
throw new Error(`failed to install plugin-local acpx: ${detail}`);
|
||||
}
|
||||
|
||||
const postcheck = await checkPinnedAcpxVersion({
|
||||
command: params.command,
|
||||
cwd: pluginRoot,
|
||||
expectedVersion,
|
||||
});
|
||||
|
||||
if (!postcheck.ok) {
|
||||
throw new Error(`plugin-local acpx verification failed after install: ${postcheck.message}`);
|
||||
}
|
||||
|
||||
params.logger?.info(`acpx plugin-local binary ready (version ${postcheck.version})`);
|
||||
})();
|
||||
|
||||
try {
|
||||
await pendingEnsure;
|
||||
} finally {
|
||||
pendingEnsure = null;
|
||||
}
|
||||
}
|
||||
140
openclaw/extensions/acpx/src/runtime-internals/events.ts
Normal file
140
openclaw/extensions/acpx/src/runtime-internals/events.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { AcpRuntimeEvent } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
asOptionalBoolean,
|
||||
asOptionalString,
|
||||
asString,
|
||||
asTrimmedString,
|
||||
type AcpxErrorEvent,
|
||||
type AcpxJsonObject,
|
||||
isRecord,
|
||||
} from "./shared.js";
|
||||
|
||||
export function toAcpxErrorEvent(value: unknown): AcpxErrorEvent | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
if (asTrimmedString(value.type) !== "error") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
message: asTrimmedString(value.message) || "acpx reported an error",
|
||||
code: asOptionalString(value.code),
|
||||
retryable: asOptionalBoolean(value.retryable),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseJsonLines(value: string): AcpxJsonObject[] {
|
||||
const events: AcpxJsonObject[] = [];
|
||||
for (const line of value.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
if (isRecord(parsed)) {
|
||||
events.push(parsed);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed lines; callers handle missing typed events via exit code.
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
export function parsePromptEventLine(line: string): AcpRuntimeEvent | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
return {
|
||||
type: "status",
|
||||
text: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isRecord(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const type = asTrimmedString(parsed.type);
|
||||
switch (type) {
|
||||
case "text": {
|
||||
const content = asString(parsed.content);
|
||||
if (content == null || content.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "text_delta",
|
||||
text: content,
|
||||
stream: "output",
|
||||
};
|
||||
}
|
||||
case "thought": {
|
||||
const content = asString(parsed.content);
|
||||
if (content == null || content.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "text_delta",
|
||||
text: content,
|
||||
stream: "thought",
|
||||
};
|
||||
}
|
||||
case "tool_call": {
|
||||
const title = asTrimmedString(parsed.title) || asTrimmedString(parsed.toolCallId) || "tool";
|
||||
const status = asTrimmedString(parsed.status);
|
||||
return {
|
||||
type: "tool_call",
|
||||
text: status ? `${title} (${status})` : title,
|
||||
};
|
||||
}
|
||||
case "client_operation": {
|
||||
const method = asTrimmedString(parsed.method) || "operation";
|
||||
const status = asTrimmedString(parsed.status);
|
||||
const summary = asTrimmedString(parsed.summary);
|
||||
const text = [method, status, summary].filter(Boolean).join(" ");
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
return { type: "status", text };
|
||||
}
|
||||
case "plan": {
|
||||
const entries = Array.isArray(parsed.entries) ? parsed.entries : [];
|
||||
const first = entries.find((entry) => isRecord(entry)) as Record<string, unknown> | undefined;
|
||||
const content = asTrimmedString(first?.content);
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
return { type: "status", text: `plan: ${content}` };
|
||||
}
|
||||
case "update": {
|
||||
const update = asTrimmedString(parsed.update);
|
||||
if (!update) {
|
||||
return null;
|
||||
}
|
||||
return { type: "status", text: update };
|
||||
}
|
||||
case "done": {
|
||||
return {
|
||||
type: "done",
|
||||
stopReason: asOptionalString(parsed.stopReason),
|
||||
};
|
||||
}
|
||||
case "error": {
|
||||
const message = asTrimmedString(parsed.message) || "acpx runtime error";
|
||||
return {
|
||||
type: "error",
|
||||
message,
|
||||
code: asOptionalString(parsed.code),
|
||||
retryable: asOptionalBoolean(parsed.retryable),
|
||||
};
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
137
openclaw/extensions/acpx/src/runtime-internals/process.ts
Normal file
137
openclaw/extensions/acpx/src/runtime-internals/process.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export type SpawnExit = {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
error: Error | null;
|
||||
};
|
||||
|
||||
type ResolvedSpawnCommand = {
|
||||
command: string;
|
||||
args: string[];
|
||||
shell?: boolean;
|
||||
};
|
||||
|
||||
function resolveSpawnCommand(params: { command: string; args: string[] }): ResolvedSpawnCommand {
|
||||
if (process.platform !== "win32") {
|
||||
return { command: params.command, args: params.args };
|
||||
}
|
||||
|
||||
const extension = path.extname(params.command).toLowerCase();
|
||||
if (extension === ".js" || extension === ".cjs" || extension === ".mjs") {
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: [params.command, ...params.args],
|
||||
};
|
||||
}
|
||||
|
||||
if (extension === ".cmd" || extension === ".bat") {
|
||||
return {
|
||||
command: params.command,
|
||||
args: params.args,
|
||||
shell: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: params.command,
|
||||
args: params.args,
|
||||
};
|
||||
}
|
||||
|
||||
export function spawnWithResolvedCommand(params: {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
}): ChildProcessWithoutNullStreams {
|
||||
const resolved = resolveSpawnCommand({
|
||||
command: params.command,
|
||||
args: params.args,
|
||||
});
|
||||
|
||||
return spawn(resolved.command, resolved.args, {
|
||||
cwd: params.cwd,
|
||||
env: process.env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
shell: resolved.shell,
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForExit(child: ChildProcessWithoutNullStreams): Promise<SpawnExit> {
|
||||
return await new Promise<SpawnExit>((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (result: SpawnExit) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
child.once("error", (err) => {
|
||||
finish({ code: null, signal: null, error: err });
|
||||
});
|
||||
|
||||
child.once("close", (code, signal) => {
|
||||
finish({ code, signal, error: null });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function spawnAndCollect(params: {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
}): Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
error: Error | null;
|
||||
}> {
|
||||
const child = spawnWithResolvedCommand(params);
|
||||
child.stdin.end();
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
|
||||
const exit = await waitForExit(child);
|
||||
return {
|
||||
stdout,
|
||||
stderr,
|
||||
code: exit.code,
|
||||
error: exit.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSpawnFailure(
|
||||
err: unknown,
|
||||
cwd: string,
|
||||
): "missing-command" | "missing-cwd" | null {
|
||||
if (!err || typeof err !== "object") {
|
||||
return null;
|
||||
}
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code !== "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
return directoryExists(cwd) ? "missing-command" : "missing-cwd";
|
||||
}
|
||||
|
||||
function directoryExists(cwd: string): boolean {
|
||||
if (!cwd) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return existsSync(cwd);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
56
openclaw/extensions/acpx/src/runtime-internals/shared.ts
Normal file
56
openclaw/extensions/acpx/src/runtime-internals/shared.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { ResolvedAcpxPluginConfig } from "../config.js";
|
||||
|
||||
export type AcpxHandleState = {
|
||||
name: string;
|
||||
agent: string;
|
||||
cwd: string;
|
||||
mode: "persistent" | "oneshot";
|
||||
acpxRecordId?: string;
|
||||
backendSessionId?: string;
|
||||
agentSessionId?: string;
|
||||
};
|
||||
|
||||
export type AcpxJsonObject = Record<string, unknown>;
|
||||
|
||||
export type AcpxErrorEvent = {
|
||||
message: string;
|
||||
code?: string;
|
||||
retryable?: boolean;
|
||||
};
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function asTrimmedString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
export function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
export function asOptionalString(value: unknown): string | undefined {
|
||||
const text = asTrimmedString(value);
|
||||
return text || undefined;
|
||||
}
|
||||
|
||||
export function asOptionalBoolean(value: unknown): boolean | undefined {
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
|
||||
export function deriveAgentFromSessionKey(sessionKey: string, fallbackAgent: string): string {
|
||||
const match = sessionKey.match(/^agent:([^:]+):/i);
|
||||
const candidate = match?.[1] ? asTrimmedString(match[1]) : "";
|
||||
return candidate || fallbackAgent;
|
||||
}
|
||||
|
||||
export function buildPermissionArgs(mode: ResolvedAcpxPluginConfig["permissionMode"]): string[] {
|
||||
if (mode === "approve-all") {
|
||||
return ["--approve-all"];
|
||||
}
|
||||
if (mode === "deny-all") {
|
||||
return ["--deny-all"];
|
||||
}
|
||||
return ["--approve-reads"];
|
||||
}
|
||||
619
openclaw/extensions/acpx/src/runtime.test.ts
Normal file
619
openclaw/extensions/acpx/src/runtime.test.ts
Normal file
@@ -0,0 +1,619 @@
|
||||
import fs from "node:fs";
|
||||
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { runAcpRuntimeAdapterContract } from "../../../src/acp/runtime/adapter-contract.testkit.js";
|
||||
import { ACPX_PINNED_VERSION, type ResolvedAcpxPluginConfig } from "./config.js";
|
||||
import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js";
|
||||
|
||||
const NOOP_LOGGER = {
|
||||
info: (_message: string) => {},
|
||||
warn: (_message: string) => {},
|
||||
error: (_message: string) => {},
|
||||
debug: (_message: string) => {},
|
||||
};
|
||||
|
||||
const MOCK_CLI_SCRIPT = String.raw`#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const logPath = process.env.MOCK_ACPX_LOG;
|
||||
const writeLog = (entry) => {
|
||||
if (!logPath) return;
|
||||
fs.appendFileSync(logPath, JSON.stringify(entry) + "\n");
|
||||
};
|
||||
|
||||
if (args.includes("--version")) {
|
||||
process.stdout.write("mock-acpx ${ACPX_PINNED_VERSION}\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (args.includes("--help")) {
|
||||
process.stdout.write("mock-acpx help\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const commandIndex = args.findIndex(
|
||||
(arg) =>
|
||||
arg === "prompt" ||
|
||||
arg === "cancel" ||
|
||||
arg === "sessions" ||
|
||||
arg === "set-mode" ||
|
||||
arg === "set" ||
|
||||
arg === "status",
|
||||
);
|
||||
const command = commandIndex >= 0 ? args[commandIndex] : "";
|
||||
const agent = commandIndex > 0 ? args[commandIndex - 1] : "unknown";
|
||||
|
||||
const readFlag = (flag) => {
|
||||
const idx = args.indexOf(flag);
|
||||
if (idx < 0) return "";
|
||||
return String(args[idx + 1] || "");
|
||||
};
|
||||
|
||||
const sessionFromOption = readFlag("--session");
|
||||
const ensureName = readFlag("--name");
|
||||
const closeName = command === "sessions" && args[commandIndex + 1] === "close" ? String(args[commandIndex + 2] || "") : "";
|
||||
const setModeValue = command === "set-mode" ? String(args[commandIndex + 1] || "") : "";
|
||||
const setKey = command === "set" ? String(args[commandIndex + 1] || "") : "";
|
||||
const setValue = command === "set" ? String(args[commandIndex + 2] || "") : "";
|
||||
|
||||
if (command === "sessions" && args[commandIndex + 1] === "ensure") {
|
||||
writeLog({ kind: "ensure", agent, args, sessionName: ensureName });
|
||||
process.stdout.write(JSON.stringify({
|
||||
type: "session_ensured",
|
||||
acpxRecordId: "rec-" + ensureName,
|
||||
acpxSessionId: "sid-" + ensureName,
|
||||
agentSessionId: "inner-" + ensureName,
|
||||
name: ensureName,
|
||||
created: true,
|
||||
}) + "\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command === "cancel") {
|
||||
writeLog({ kind: "cancel", agent, args, sessionName: sessionFromOption });
|
||||
process.stdout.write(JSON.stringify({
|
||||
acpxSessionId: "sid-" + sessionFromOption,
|
||||
cancelled: true,
|
||||
}) + "\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command === "set-mode") {
|
||||
writeLog({ kind: "set-mode", agent, args, sessionName: sessionFromOption, mode: setModeValue });
|
||||
process.stdout.write(JSON.stringify({
|
||||
type: "mode_set",
|
||||
acpxSessionId: "sid-" + sessionFromOption,
|
||||
mode: setModeValue,
|
||||
}) + "\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command === "set") {
|
||||
writeLog({
|
||||
kind: "set",
|
||||
agent,
|
||||
args,
|
||||
sessionName: sessionFromOption,
|
||||
key: setKey,
|
||||
value: setValue,
|
||||
});
|
||||
process.stdout.write(JSON.stringify({
|
||||
type: "config_set",
|
||||
acpxSessionId: "sid-" + sessionFromOption,
|
||||
key: setKey,
|
||||
value: setValue,
|
||||
}) + "\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command === "status") {
|
||||
writeLog({ kind: "status", agent, args, sessionName: sessionFromOption });
|
||||
process.stdout.write(JSON.stringify({
|
||||
acpxRecordId: sessionFromOption ? "rec-" + sessionFromOption : null,
|
||||
acpxSessionId: sessionFromOption ? "sid-" + sessionFromOption : null,
|
||||
agentSessionId: sessionFromOption ? "inner-" + sessionFromOption : null,
|
||||
status: sessionFromOption ? "alive" : "no-session",
|
||||
pid: 4242,
|
||||
uptime: 120,
|
||||
}) + "\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command === "sessions" && args[commandIndex + 1] === "close") {
|
||||
writeLog({ kind: "close", agent, args, sessionName: closeName });
|
||||
process.stdout.write(JSON.stringify({
|
||||
type: "session_closed",
|
||||
acpxRecordId: "rec-" + closeName,
|
||||
acpxSessionId: "sid-" + closeName,
|
||||
name: closeName,
|
||||
}) + "\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command === "prompt") {
|
||||
const stdinText = fs.readFileSync(0, "utf8");
|
||||
writeLog({ kind: "prompt", agent, args, sessionName: sessionFromOption, stdinText });
|
||||
const acpxSessionId = "sid-" + sessionFromOption;
|
||||
|
||||
if (stdinText.includes("trigger-error")) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
eventVersion: 1,
|
||||
acpxSessionId,
|
||||
requestId: "req-1",
|
||||
seq: 0,
|
||||
stream: "prompt",
|
||||
type: "error",
|
||||
code: "RUNTIME",
|
||||
message: "mock failure",
|
||||
}) + "\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (stdinText.includes("split-spacing")) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
eventVersion: 1,
|
||||
acpxSessionId,
|
||||
requestId: "req-1",
|
||||
seq: 0,
|
||||
stream: "prompt",
|
||||
type: "text",
|
||||
content: "alpha",
|
||||
}) + "\n");
|
||||
process.stdout.write(JSON.stringify({
|
||||
eventVersion: 1,
|
||||
acpxSessionId,
|
||||
requestId: "req-1",
|
||||
seq: 1,
|
||||
stream: "prompt",
|
||||
type: "text",
|
||||
content: " beta",
|
||||
}) + "\n");
|
||||
process.stdout.write(JSON.stringify({
|
||||
eventVersion: 1,
|
||||
acpxSessionId,
|
||||
requestId: "req-1",
|
||||
seq: 2,
|
||||
stream: "prompt",
|
||||
type: "text",
|
||||
content: " gamma",
|
||||
}) + "\n");
|
||||
process.stdout.write(JSON.stringify({
|
||||
eventVersion: 1,
|
||||
acpxSessionId,
|
||||
requestId: "req-1",
|
||||
seq: 3,
|
||||
stream: "prompt",
|
||||
type: "done",
|
||||
stopReason: "end_turn",
|
||||
}) + "\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify({
|
||||
eventVersion: 1,
|
||||
acpxSessionId,
|
||||
requestId: "req-1",
|
||||
seq: 0,
|
||||
stream: "prompt",
|
||||
type: "thought",
|
||||
content: "thinking",
|
||||
}) + "\n");
|
||||
process.stdout.write(JSON.stringify({
|
||||
eventVersion: 1,
|
||||
acpxSessionId,
|
||||
requestId: "req-1",
|
||||
seq: 1,
|
||||
stream: "prompt",
|
||||
type: "tool_call",
|
||||
title: "run-tests",
|
||||
status: "in_progress",
|
||||
}) + "\n");
|
||||
process.stdout.write(JSON.stringify({
|
||||
eventVersion: 1,
|
||||
acpxSessionId,
|
||||
requestId: "req-1",
|
||||
seq: 2,
|
||||
stream: "prompt",
|
||||
type: "text",
|
||||
content: "echo:" + stdinText.trim(),
|
||||
}) + "\n");
|
||||
process.stdout.write(JSON.stringify({
|
||||
eventVersion: 1,
|
||||
acpxSessionId,
|
||||
requestId: "req-1",
|
||||
seq: 3,
|
||||
stream: "prompt",
|
||||
type: "done",
|
||||
stopReason: "end_turn",
|
||||
}) + "\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
writeLog({ kind: "unknown", args });
|
||||
process.stdout.write(JSON.stringify({
|
||||
eventVersion: 1,
|
||||
acpxSessionId: "unknown",
|
||||
seq: 0,
|
||||
stream: "control",
|
||||
type: "error",
|
||||
code: "USAGE",
|
||||
message: "unknown command",
|
||||
}) + "\n");
|
||||
process.exit(2);
|
||||
`;
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createMockRuntime(params?: {
|
||||
permissionMode?: ResolvedAcpxPluginConfig["permissionMode"];
|
||||
queueOwnerTtlSeconds?: number;
|
||||
}): Promise<{
|
||||
runtime: AcpxRuntime;
|
||||
logPath: string;
|
||||
config: ResolvedAcpxPluginConfig;
|
||||
}> {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-acpx-runtime-test-"));
|
||||
tempDirs.push(dir);
|
||||
const scriptPath = path.join(dir, "mock-acpx.cjs");
|
||||
const logPath = path.join(dir, "calls.log");
|
||||
await writeFile(scriptPath, MOCK_CLI_SCRIPT, "utf8");
|
||||
await chmod(scriptPath, 0o755);
|
||||
process.env.MOCK_ACPX_LOG = logPath;
|
||||
|
||||
const config: ResolvedAcpxPluginConfig = {
|
||||
command: scriptPath,
|
||||
cwd: dir,
|
||||
permissionMode: params?.permissionMode ?? "approve-all",
|
||||
nonInteractivePermissions: "fail",
|
||||
queueOwnerTtlSeconds: params?.queueOwnerTtlSeconds ?? 0.1,
|
||||
};
|
||||
|
||||
return {
|
||||
runtime: new AcpxRuntime(config, {
|
||||
queueOwnerTtlSeconds: params?.queueOwnerTtlSeconds,
|
||||
logger: NOOP_LOGGER,
|
||||
}),
|
||||
logPath,
|
||||
config,
|
||||
};
|
||||
}
|
||||
|
||||
async function readLogEntries(logPath: string): Promise<Array<Record<string, unknown>>> {
|
||||
if (!fs.existsSync(logPath)) {
|
||||
return [];
|
||||
}
|
||||
const raw = await readFile(logPath, "utf8");
|
||||
return raw
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.MOCK_ACPX_LOG;
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (!dir) {
|
||||
continue;
|
||||
}
|
||||
await rm(dir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 10,
|
||||
retryDelay: 10,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("AcpxRuntime", () => {
|
||||
it("passes the shared ACP adapter contract suite", async () => {
|
||||
const fixture = await createMockRuntime();
|
||||
await runAcpRuntimeAdapterContract({
|
||||
createRuntime: async () => fixture.runtime,
|
||||
agentId: "codex",
|
||||
successPrompt: "contract-pass",
|
||||
errorPrompt: "trigger-error",
|
||||
assertSuccessEvents: (events) => {
|
||||
expect(events.some((event) => event.type === "done")).toBe(true);
|
||||
},
|
||||
assertErrorOutcome: ({ events, thrown }) => {
|
||||
expect(events.some((event) => event.type === "error") || Boolean(thrown)).toBe(true);
|
||||
},
|
||||
});
|
||||
|
||||
const logs = await readLogEntries(fixture.logPath);
|
||||
expect(logs.some((entry) => entry.kind === "ensure")).toBe(true);
|
||||
expect(logs.some((entry) => entry.kind === "status")).toBe(true);
|
||||
expect(logs.some((entry) => entry.kind === "set-mode")).toBe(true);
|
||||
expect(logs.some((entry) => entry.kind === "set")).toBe(true);
|
||||
expect(logs.some((entry) => entry.kind === "cancel")).toBe(true);
|
||||
expect(logs.some((entry) => entry.kind === "close")).toBe(true);
|
||||
});
|
||||
|
||||
it("ensures sessions and streams prompt events", async () => {
|
||||
const { runtime, logPath } = await createMockRuntime({ queueOwnerTtlSeconds: 180 });
|
||||
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:acp:123",
|
||||
agent: "codex",
|
||||
mode: "persistent",
|
||||
});
|
||||
expect(handle.backend).toBe("acpx");
|
||||
expect(handle.acpxRecordId).toBe("rec-agent:codex:acp:123");
|
||||
expect(handle.agentSessionId).toBe("inner-agent:codex:acp:123");
|
||||
expect(handle.backendSessionId).toBe("sid-agent:codex:acp:123");
|
||||
const decoded = decodeAcpxRuntimeHandleState(handle.runtimeSessionName);
|
||||
expect(decoded?.acpxRecordId).toBe("rec-agent:codex:acp:123");
|
||||
expect(decoded?.agentSessionId).toBe("inner-agent:codex:acp:123");
|
||||
expect(decoded?.backendSessionId).toBe("sid-agent:codex:acp:123");
|
||||
|
||||
const events = [];
|
||||
for await (const event of runtime.runTurn({
|
||||
handle,
|
||||
text: "hello world",
|
||||
mode: "prompt",
|
||||
requestId: "req-test",
|
||||
})) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(events).toContainEqual({
|
||||
type: "text_delta",
|
||||
text: "thinking",
|
||||
stream: "thought",
|
||||
});
|
||||
expect(events).toContainEqual({
|
||||
type: "tool_call",
|
||||
text: "run-tests (in_progress)",
|
||||
});
|
||||
expect(events).toContainEqual({
|
||||
type: "text_delta",
|
||||
text: "echo:hello world",
|
||||
stream: "output",
|
||||
});
|
||||
expect(events).toContainEqual({
|
||||
type: "done",
|
||||
stopReason: "end_turn",
|
||||
});
|
||||
|
||||
const logs = await readLogEntries(logPath);
|
||||
const ensure = logs.find((entry) => entry.kind === "ensure");
|
||||
const prompt = logs.find((entry) => entry.kind === "prompt");
|
||||
expect(ensure).toBeDefined();
|
||||
expect(prompt).toBeDefined();
|
||||
expect(Array.isArray(prompt?.args)).toBe(true);
|
||||
const promptArgs = (prompt?.args as string[]) ?? [];
|
||||
expect(promptArgs).toContain("--ttl");
|
||||
expect(promptArgs).toContain("180");
|
||||
expect(promptArgs).toContain("--approve-all");
|
||||
});
|
||||
|
||||
it("passes a queue-owner TTL by default to avoid long idle stalls", async () => {
|
||||
const { runtime, logPath } = await createMockRuntime();
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:acp:ttl-default",
|
||||
agent: "codex",
|
||||
mode: "persistent",
|
||||
});
|
||||
|
||||
for await (const _event of runtime.runTurn({
|
||||
handle,
|
||||
text: "ttl-default",
|
||||
mode: "prompt",
|
||||
requestId: "req-ttl-default",
|
||||
})) {
|
||||
// drain
|
||||
}
|
||||
|
||||
const logs = await readLogEntries(logPath);
|
||||
const prompt = logs.find((entry) => entry.kind === "prompt");
|
||||
expect(prompt).toBeDefined();
|
||||
const promptArgs = (prompt?.args as string[]) ?? [];
|
||||
const ttlFlagIndex = promptArgs.indexOf("--ttl");
|
||||
expect(ttlFlagIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(promptArgs[ttlFlagIndex + 1]).toBe("0.1");
|
||||
});
|
||||
|
||||
it("preserves leading spaces across streamed text deltas", async () => {
|
||||
const { runtime } = await createMockRuntime();
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:acp:space",
|
||||
agent: "codex",
|
||||
mode: "persistent",
|
||||
});
|
||||
|
||||
const textDeltas: string[] = [];
|
||||
for await (const event of runtime.runTurn({
|
||||
handle,
|
||||
text: "split-spacing",
|
||||
mode: "prompt",
|
||||
requestId: "req-space",
|
||||
})) {
|
||||
if (event.type === "text_delta" && event.stream === "output") {
|
||||
textDeltas.push(event.text);
|
||||
}
|
||||
}
|
||||
|
||||
expect(textDeltas).toEqual(["alpha", " beta", " gamma"]);
|
||||
expect(textDeltas.join("")).toBe("alpha beta gamma");
|
||||
});
|
||||
|
||||
it("maps acpx error events into ACP runtime error events", async () => {
|
||||
const { runtime } = await createMockRuntime();
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:acp:456",
|
||||
agent: "codex",
|
||||
mode: "persistent",
|
||||
});
|
||||
|
||||
const events = [];
|
||||
for await (const event of runtime.runTurn({
|
||||
handle,
|
||||
text: "trigger-error",
|
||||
mode: "prompt",
|
||||
requestId: "req-err",
|
||||
})) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(events).toContainEqual({
|
||||
type: "error",
|
||||
message: "mock failure",
|
||||
code: "RUNTIME",
|
||||
retryable: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("supports cancel and close using encoded runtime handle state", async () => {
|
||||
const { runtime, logPath, config } = await createMockRuntime();
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:claude:acp:789",
|
||||
agent: "claude",
|
||||
mode: "persistent",
|
||||
});
|
||||
|
||||
const decoded = decodeAcpxRuntimeHandleState(handle.runtimeSessionName);
|
||||
expect(decoded?.name).toBe("agent:claude:acp:789");
|
||||
|
||||
const secondRuntime = new AcpxRuntime(config, { logger: NOOP_LOGGER });
|
||||
|
||||
await secondRuntime.cancel({ handle, reason: "test" });
|
||||
await secondRuntime.close({ handle, reason: "test" });
|
||||
|
||||
const logs = await readLogEntries(logPath);
|
||||
const cancel = logs.find((entry) => entry.kind === "cancel");
|
||||
const close = logs.find((entry) => entry.kind === "close");
|
||||
expect(cancel?.sessionName).toBe("agent:claude:acp:789");
|
||||
expect(close?.sessionName).toBe("agent:claude:acp:789");
|
||||
});
|
||||
|
||||
it("exposes control capabilities and runs set-mode/set/status commands", async () => {
|
||||
const { runtime, logPath } = await createMockRuntime();
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:acp:controls",
|
||||
agent: "codex",
|
||||
mode: "persistent",
|
||||
});
|
||||
|
||||
const capabilities = runtime.getCapabilities();
|
||||
expect(capabilities.controls).toContain("session/set_mode");
|
||||
expect(capabilities.controls).toContain("session/set_config_option");
|
||||
expect(capabilities.controls).toContain("session/status");
|
||||
|
||||
await runtime.setMode({
|
||||
handle,
|
||||
mode: "plan",
|
||||
});
|
||||
await runtime.setConfigOption({
|
||||
handle,
|
||||
key: "model",
|
||||
value: "openai-codex/gpt-5.3-codex",
|
||||
});
|
||||
const status = await runtime.getStatus({ handle });
|
||||
const ensuredSessionName = "agent:codex:acp:controls";
|
||||
|
||||
expect(status.summary).toContain("status=alive");
|
||||
expect(status.acpxRecordId).toBe("rec-" + ensuredSessionName);
|
||||
expect(status.backendSessionId).toBe("sid-" + ensuredSessionName);
|
||||
expect(status.agentSessionId).toBe("inner-" + ensuredSessionName);
|
||||
expect(status.details?.acpxRecordId).toBe("rec-" + ensuredSessionName);
|
||||
expect(status.details?.status).toBe("alive");
|
||||
expect(status.details?.pid).toBe(4242);
|
||||
|
||||
const logs = await readLogEntries(logPath);
|
||||
expect(logs.find((entry) => entry.kind === "set-mode")?.mode).toBe("plan");
|
||||
expect(logs.find((entry) => entry.kind === "set")?.key).toBe("model");
|
||||
expect(logs.find((entry) => entry.kind === "status")).toBeDefined();
|
||||
});
|
||||
|
||||
it("skips prompt execution when runTurn starts with an already-aborted signal", async () => {
|
||||
const { runtime, logPath } = await createMockRuntime();
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:acp:aborted",
|
||||
agent: "codex",
|
||||
mode: "persistent",
|
||||
});
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
const events = [];
|
||||
for await (const event of runtime.runTurn({
|
||||
handle,
|
||||
text: "should-not-run",
|
||||
mode: "prompt",
|
||||
requestId: "req-aborted",
|
||||
signal: controller.signal,
|
||||
})) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
const logs = await readLogEntries(logPath);
|
||||
expect(events).toEqual([]);
|
||||
expect(logs.some((entry) => entry.kind === "prompt")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not mark backend unhealthy when a per-session cwd is missing", async () => {
|
||||
const { runtime } = await createMockRuntime();
|
||||
const missingCwd = path.join(os.tmpdir(), "openclaw-acpx-runtime-test-missing-cwd");
|
||||
|
||||
await runtime.probeAvailability();
|
||||
expect(runtime.isHealthy()).toBe(true);
|
||||
|
||||
await expect(
|
||||
runtime.ensureSession({
|
||||
sessionKey: "agent:codex:acp:missing-cwd",
|
||||
agent: "codex",
|
||||
mode: "persistent",
|
||||
cwd: missingCwd,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "ACP_SESSION_INIT_FAILED",
|
||||
message: expect.stringContaining("working directory does not exist"),
|
||||
});
|
||||
expect(runtime.isHealthy()).toBe(true);
|
||||
});
|
||||
|
||||
it("marks runtime unhealthy when command is missing", async () => {
|
||||
const runtime = new AcpxRuntime(
|
||||
{
|
||||
command: "/definitely/missing/acpx",
|
||||
cwd: process.cwd(),
|
||||
permissionMode: "approve-reads",
|
||||
nonInteractivePermissions: "fail",
|
||||
queueOwnerTtlSeconds: 0.1,
|
||||
},
|
||||
{ logger: NOOP_LOGGER },
|
||||
);
|
||||
|
||||
await runtime.probeAvailability();
|
||||
expect(runtime.isHealthy()).toBe(false);
|
||||
});
|
||||
|
||||
it("marks runtime healthy when command is available", async () => {
|
||||
const { runtime } = await createMockRuntime();
|
||||
await runtime.probeAvailability();
|
||||
expect(runtime.isHealthy()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns doctor report for missing command", async () => {
|
||||
const runtime = new AcpxRuntime(
|
||||
{
|
||||
command: "/definitely/missing/acpx",
|
||||
cwd: process.cwd(),
|
||||
permissionMode: "approve-reads",
|
||||
nonInteractivePermissions: "fail",
|
||||
queueOwnerTtlSeconds: 0.1,
|
||||
},
|
||||
{ logger: NOOP_LOGGER },
|
||||
);
|
||||
|
||||
const report = await runtime.doctor();
|
||||
expect(report.ok).toBe(false);
|
||||
expect(report.code).toBe("ACP_BACKEND_UNAVAILABLE");
|
||||
expect(report.installCommand).toContain("acpx");
|
||||
});
|
||||
});
|
||||
578
openclaw/extensions/acpx/src/runtime.ts
Normal file
578
openclaw/extensions/acpx/src/runtime.ts
Normal file
@@ -0,0 +1,578 @@
|
||||
import { createInterface } from "node:readline";
|
||||
import type {
|
||||
AcpRuntimeCapabilities,
|
||||
AcpRuntimeDoctorReport,
|
||||
AcpRuntime,
|
||||
AcpRuntimeEnsureInput,
|
||||
AcpRuntimeErrorCode,
|
||||
AcpRuntimeEvent,
|
||||
AcpRuntimeHandle,
|
||||
AcpRuntimeStatus,
|
||||
AcpRuntimeTurnInput,
|
||||
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 {
|
||||
parseJsonLines,
|
||||
parsePromptEventLine,
|
||||
toAcpxErrorEvent,
|
||||
} from "./runtime-internals/events.js";
|
||||
import {
|
||||
resolveSpawnFailure,
|
||||
spawnAndCollect,
|
||||
spawnWithResolvedCommand,
|
||||
waitForExit,
|
||||
} from "./runtime-internals/process.js";
|
||||
import {
|
||||
asOptionalString,
|
||||
asTrimmedString,
|
||||
buildPermissionArgs,
|
||||
deriveAgentFromSessionKey,
|
||||
isRecord,
|
||||
type AcpxHandleState,
|
||||
type AcpxJsonObject,
|
||||
} from "./runtime-internals/shared.js";
|
||||
|
||||
export const ACPX_BACKEND_ID = "acpx";
|
||||
|
||||
const ACPX_RUNTIME_HANDLE_PREFIX = "acpx:v1:";
|
||||
const DEFAULT_AGENT_FALLBACK = "codex";
|
||||
const ACPX_CAPABILITIES: AcpRuntimeCapabilities = {
|
||||
controls: ["session/set_mode", "session/set_config_option", "session/status"],
|
||||
};
|
||||
|
||||
export function encodeAcpxRuntimeHandleState(state: AcpxHandleState): string {
|
||||
const payload = Buffer.from(JSON.stringify(state), "utf8").toString("base64url");
|
||||
return `${ACPX_RUNTIME_HANDLE_PREFIX}${payload}`;
|
||||
}
|
||||
|
||||
export function decodeAcpxRuntimeHandleState(runtimeSessionName: string): AcpxHandleState | null {
|
||||
const trimmed = runtimeSessionName.trim();
|
||||
if (!trimmed.startsWith(ACPX_RUNTIME_HANDLE_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
const encoded = trimmed.slice(ACPX_RUNTIME_HANDLE_PREFIX.length);
|
||||
if (!encoded) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const raw = Buffer.from(encoded, "base64url").toString("utf8");
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!isRecord(parsed)) {
|
||||
return null;
|
||||
}
|
||||
const name = asTrimmedString(parsed.name);
|
||||
const agent = asTrimmedString(parsed.agent);
|
||||
const cwd = asTrimmedString(parsed.cwd);
|
||||
const mode = asTrimmedString(parsed.mode);
|
||||
const acpxRecordId = asOptionalString(parsed.acpxRecordId);
|
||||
const backendSessionId = asOptionalString(parsed.backendSessionId);
|
||||
const agentSessionId = asOptionalString(parsed.agentSessionId);
|
||||
if (!name || !agent || !cwd) {
|
||||
return null;
|
||||
}
|
||||
if (mode !== "persistent" && mode !== "oneshot") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name,
|
||||
agent,
|
||||
cwd,
|
||||
mode,
|
||||
...(acpxRecordId ? { acpxRecordId } : {}),
|
||||
...(backendSessionId ? { backendSessionId } : {}),
|
||||
...(agentSessionId ? { agentSessionId } : {}),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class AcpxRuntime implements AcpRuntime {
|
||||
private healthy = false;
|
||||
private readonly logger?: PluginLogger;
|
||||
private readonly queueOwnerTtlSeconds: number;
|
||||
|
||||
constructor(
|
||||
private readonly config: ResolvedAcpxPluginConfig,
|
||||
opts?: {
|
||||
logger?: PluginLogger;
|
||||
queueOwnerTtlSeconds?: number;
|
||||
},
|
||||
) {
|
||||
this.logger = opts?.logger;
|
||||
const requestedQueueOwnerTtlSeconds = opts?.queueOwnerTtlSeconds;
|
||||
this.queueOwnerTtlSeconds =
|
||||
typeof requestedQueueOwnerTtlSeconds === "number" &&
|
||||
Number.isFinite(requestedQueueOwnerTtlSeconds) &&
|
||||
requestedQueueOwnerTtlSeconds >= 0
|
||||
? requestedQueueOwnerTtlSeconds
|
||||
: this.config.queueOwnerTtlSeconds;
|
||||
}
|
||||
|
||||
isHealthy(): boolean {
|
||||
return this.healthy;
|
||||
}
|
||||
|
||||
async probeAvailability(): Promise<void> {
|
||||
const versionCheck = await checkPinnedAcpxVersion({
|
||||
command: this.config.command,
|
||||
cwd: this.config.cwd,
|
||||
expectedVersion: ACPX_PINNED_VERSION,
|
||||
});
|
||||
if (!versionCheck.ok) {
|
||||
this.healthy = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await spawnAndCollect({
|
||||
command: this.config.command,
|
||||
args: ["--help"],
|
||||
cwd: this.config.cwd,
|
||||
});
|
||||
this.healthy = result.error == null && (result.code ?? 0) === 0;
|
||||
} catch {
|
||||
this.healthy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle> {
|
||||
const sessionName = asTrimmedString(input.sessionKey);
|
||||
if (!sessionName) {
|
||||
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
|
||||
}
|
||||
const agent = asTrimmedString(input.agent);
|
||||
if (!agent) {
|
||||
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP agent id is required.");
|
||||
}
|
||||
const cwd = asTrimmedString(input.cwd) || this.config.cwd;
|
||||
const mode = input.mode;
|
||||
|
||||
const events = await this.runControlCommand({
|
||||
args: this.buildControlArgs({
|
||||
cwd,
|
||||
command: [agent, "sessions", "ensure", "--name", sessionName],
|
||||
}),
|
||||
cwd,
|
||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||
});
|
||||
const ensuredEvent = events.find(
|
||||
(event) =>
|
||||
asOptionalString(event.agentSessionId) ||
|
||||
asOptionalString(event.acpxSessionId) ||
|
||||
asOptionalString(event.acpxRecordId),
|
||||
);
|
||||
const acpxRecordId = ensuredEvent ? asOptionalString(ensuredEvent.acpxRecordId) : undefined;
|
||||
const agentSessionId = ensuredEvent ? asOptionalString(ensuredEvent.agentSessionId) : undefined;
|
||||
const backendSessionId = ensuredEvent
|
||||
? asOptionalString(ensuredEvent.acpxSessionId)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
sessionKey: input.sessionKey,
|
||||
backend: ACPX_BACKEND_ID,
|
||||
runtimeSessionName: encodeAcpxRuntimeHandleState({
|
||||
name: sessionName,
|
||||
agent,
|
||||
cwd,
|
||||
mode,
|
||||
...(acpxRecordId ? { acpxRecordId } : {}),
|
||||
...(backendSessionId ? { backendSessionId } : {}),
|
||||
...(agentSessionId ? { agentSessionId } : {}),
|
||||
}),
|
||||
cwd,
|
||||
...(acpxRecordId ? { acpxRecordId } : {}),
|
||||
...(backendSessionId ? { backendSessionId } : {}),
|
||||
...(agentSessionId ? { agentSessionId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async *runTurn(input: AcpRuntimeTurnInput): AsyncIterable<AcpRuntimeEvent> {
|
||||
const state = this.resolveHandleState(input.handle);
|
||||
const args = this.buildPromptArgs({
|
||||
agent: state.agent,
|
||||
sessionName: state.name,
|
||||
cwd: state.cwd,
|
||||
});
|
||||
|
||||
const cancelOnAbort = async () => {
|
||||
await this.cancel({
|
||||
handle: input.handle,
|
||||
reason: "abort-signal",
|
||||
}).catch((err) => {
|
||||
this.logger?.warn?.(`acpx runtime abort-cancel failed: ${String(err)}`);
|
||||
});
|
||||
};
|
||||
const onAbort = () => {
|
||||
void cancelOnAbort();
|
||||
};
|
||||
|
||||
if (input.signal?.aborted) {
|
||||
await cancelOnAbort();
|
||||
return;
|
||||
}
|
||||
if (input.signal) {
|
||||
input.signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
const child = spawnWithResolvedCommand({
|
||||
command: this.config.command,
|
||||
args,
|
||||
cwd: state.cwd,
|
||||
});
|
||||
child.stdin.on("error", () => {
|
||||
// Ignore EPIPE when the child exits before stdin flush completes.
|
||||
});
|
||||
|
||||
child.stdin.end(input.text);
|
||||
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
|
||||
let sawDone = false;
|
||||
let sawError = false;
|
||||
const lines = createInterface({ input: child.stdout });
|
||||
try {
|
||||
for await (const line of lines) {
|
||||
const parsed = parsePromptEventLine(line);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
if (parsed.type === "done") {
|
||||
sawDone = true;
|
||||
}
|
||||
if (parsed.type === "error") {
|
||||
sawError = true;
|
||||
}
|
||||
yield parsed;
|
||||
}
|
||||
|
||||
const exit = await waitForExit(child);
|
||||
if (exit.error) {
|
||||
const spawnFailure = resolveSpawnFailure(exit.error, state.cwd);
|
||||
if (spawnFailure === "missing-command") {
|
||||
this.healthy = false;
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_BACKEND_UNAVAILABLE",
|
||||
`acpx command not found: ${this.config.command}`,
|
||||
{ cause: exit.error },
|
||||
);
|
||||
}
|
||||
if (spawnFailure === "missing-cwd") {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_TURN_FAILED",
|
||||
`ACP runtime working directory does not exist: ${state.cwd}`,
|
||||
{ cause: exit.error },
|
||||
);
|
||||
}
|
||||
throw new AcpRuntimeError("ACP_TURN_FAILED", exit.error.message, { cause: exit.error });
|
||||
}
|
||||
|
||||
if ((exit.code ?? 0) !== 0 && !sawError) {
|
||||
yield {
|
||||
type: "error",
|
||||
message: stderr.trim() || `acpx exited with code ${exit.code ?? "unknown"}`,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sawDone && !sawError) {
|
||||
yield { type: "done" };
|
||||
}
|
||||
} finally {
|
||||
lines.close();
|
||||
if (input.signal) {
|
||||
input.signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getCapabilities(): AcpRuntimeCapabilities {
|
||||
return ACPX_CAPABILITIES;
|
||||
}
|
||||
|
||||
async getStatus(input: { handle: AcpRuntimeHandle }): Promise<AcpRuntimeStatus> {
|
||||
const state = this.resolveHandleState(input.handle);
|
||||
const events = await this.runControlCommand({
|
||||
args: this.buildControlArgs({
|
||||
cwd: state.cwd,
|
||||
command: [state.agent, "status", "--session", state.name],
|
||||
}),
|
||||
cwd: state.cwd,
|
||||
fallbackCode: "ACP_TURN_FAILED",
|
||||
ignoreNoSession: true,
|
||||
});
|
||||
const detail = events.find((event) => !toAcpxErrorEvent(event)) ?? events[0];
|
||||
if (!detail) {
|
||||
return {
|
||||
summary: "acpx status unavailable",
|
||||
};
|
||||
}
|
||||
const status = asTrimmedString(detail.status) || "unknown";
|
||||
const acpxRecordId = asOptionalString(detail.acpxRecordId);
|
||||
const acpxSessionId = asOptionalString(detail.acpxSessionId);
|
||||
const agentSessionId = asOptionalString(detail.agentSessionId);
|
||||
const pid = typeof detail.pid === "number" && Number.isFinite(detail.pid) ? detail.pid : null;
|
||||
const summary = [
|
||||
`status=${status}`,
|
||||
acpxRecordId ? `acpxRecordId=${acpxRecordId}` : null,
|
||||
acpxSessionId ? `acpxSessionId=${acpxSessionId}` : null,
|
||||
pid != null ? `pid=${pid}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return {
|
||||
summary,
|
||||
...(acpxRecordId ? { acpxRecordId } : {}),
|
||||
...(acpxSessionId ? { backendSessionId: acpxSessionId } : {}),
|
||||
...(agentSessionId ? { agentSessionId } : {}),
|
||||
details: detail,
|
||||
};
|
||||
}
|
||||
|
||||
async setMode(input: { handle: AcpRuntimeHandle; mode: string }): Promise<void> {
|
||||
const state = this.resolveHandleState(input.handle);
|
||||
const mode = asTrimmedString(input.mode);
|
||||
if (!mode) {
|
||||
throw new AcpRuntimeError("ACP_TURN_FAILED", "ACP runtime mode is required.");
|
||||
}
|
||||
await this.runControlCommand({
|
||||
args: this.buildControlArgs({
|
||||
cwd: state.cwd,
|
||||
command: [state.agent, "set-mode", mode, "--session", state.name],
|
||||
}),
|
||||
cwd: state.cwd,
|
||||
fallbackCode: "ACP_TURN_FAILED",
|
||||
});
|
||||
}
|
||||
|
||||
async setConfigOption(input: {
|
||||
handle: AcpRuntimeHandle;
|
||||
key: string;
|
||||
value: string;
|
||||
}): Promise<void> {
|
||||
const state = this.resolveHandleState(input.handle);
|
||||
const key = asTrimmedString(input.key);
|
||||
const value = asTrimmedString(input.value);
|
||||
if (!key || !value) {
|
||||
throw new AcpRuntimeError("ACP_TURN_FAILED", "ACP config option key/value are required.");
|
||||
}
|
||||
await this.runControlCommand({
|
||||
args: this.buildControlArgs({
|
||||
cwd: state.cwd,
|
||||
command: [state.agent, "set", key, value, "--session", state.name],
|
||||
}),
|
||||
cwd: state.cwd,
|
||||
fallbackCode: "ACP_TURN_FAILED",
|
||||
});
|
||||
}
|
||||
|
||||
async doctor(): Promise<AcpRuntimeDoctorReport> {
|
||||
const versionCheck = await checkPinnedAcpxVersion({
|
||||
command: this.config.command,
|
||||
cwd: this.config.cwd,
|
||||
expectedVersion: ACPX_PINNED_VERSION,
|
||||
});
|
||||
if (!versionCheck.ok) {
|
||||
this.healthy = false;
|
||||
const details = [
|
||||
`expected=${versionCheck.expectedVersion}`,
|
||||
versionCheck.installedVersion ? `installed=${versionCheck.installedVersion}` : null,
|
||||
].filter((detail): detail is string => Boolean(detail));
|
||||
return {
|
||||
ok: false,
|
||||
code: "ACP_BACKEND_UNAVAILABLE",
|
||||
message: versionCheck.message,
|
||||
installCommand: versionCheck.installCommand,
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await spawnAndCollect({
|
||||
command: this.config.command,
|
||||
args: ["--help"],
|
||||
cwd: this.config.cwd,
|
||||
});
|
||||
if (result.error) {
|
||||
const spawnFailure = resolveSpawnFailure(result.error, this.config.cwd);
|
||||
if (spawnFailure === "missing-command") {
|
||||
this.healthy = false;
|
||||
return {
|
||||
ok: false,
|
||||
code: "ACP_BACKEND_UNAVAILABLE",
|
||||
message: `acpx command not found: ${this.config.command}`,
|
||||
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
|
||||
};
|
||||
}
|
||||
if (spawnFailure === "missing-cwd") {
|
||||
this.healthy = false;
|
||||
return {
|
||||
ok: false,
|
||||
code: "ACP_BACKEND_UNAVAILABLE",
|
||||
message: `ACP runtime working directory does not exist: ${this.config.cwd}`,
|
||||
};
|
||||
}
|
||||
this.healthy = false;
|
||||
return {
|
||||
ok: false,
|
||||
code: "ACP_BACKEND_UNAVAILABLE",
|
||||
message: result.error.message,
|
||||
details: [String(result.error)],
|
||||
};
|
||||
}
|
||||
if ((result.code ?? 0) !== 0) {
|
||||
this.healthy = false;
|
||||
return {
|
||||
ok: false,
|
||||
code: "ACP_BACKEND_UNAVAILABLE",
|
||||
message: result.stderr.trim() || `acpx exited with code ${result.code ?? "unknown"}`,
|
||||
};
|
||||
}
|
||||
this.healthy = true;
|
||||
return {
|
||||
ok: true,
|
||||
message: `acpx command available (${this.config.command}, version ${versionCheck.version})`,
|
||||
};
|
||||
} catch (error) {
|
||||
this.healthy = false;
|
||||
return {
|
||||
ok: false,
|
||||
code: "ACP_BACKEND_UNAVAILABLE",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void> {
|
||||
const state = this.resolveHandleState(input.handle);
|
||||
await this.runControlCommand({
|
||||
args: this.buildControlArgs({
|
||||
cwd: state.cwd,
|
||||
command: [state.agent, "cancel", "--session", state.name],
|
||||
}),
|
||||
cwd: state.cwd,
|
||||
fallbackCode: "ACP_TURN_FAILED",
|
||||
ignoreNoSession: true,
|
||||
});
|
||||
}
|
||||
|
||||
async close(input: { handle: AcpRuntimeHandle; reason: string }): Promise<void> {
|
||||
const state = this.resolveHandleState(input.handle);
|
||||
await this.runControlCommand({
|
||||
args: this.buildControlArgs({
|
||||
cwd: state.cwd,
|
||||
command: [state.agent, "sessions", "close", state.name],
|
||||
}),
|
||||
cwd: state.cwd,
|
||||
fallbackCode: "ACP_TURN_FAILED",
|
||||
ignoreNoSession: true,
|
||||
});
|
||||
}
|
||||
|
||||
private resolveHandleState(handle: AcpRuntimeHandle): AcpxHandleState {
|
||||
const decoded = decodeAcpxRuntimeHandleState(handle.runtimeSessionName);
|
||||
if (decoded) {
|
||||
return decoded;
|
||||
}
|
||||
|
||||
const legacyName = asTrimmedString(handle.runtimeSessionName);
|
||||
if (!legacyName) {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
"Invalid acpx runtime handle: runtimeSessionName is missing.",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
name: legacyName,
|
||||
agent: deriveAgentFromSessionKey(handle.sessionKey, DEFAULT_AGENT_FALLBACK),
|
||||
cwd: this.config.cwd,
|
||||
mode: "persistent",
|
||||
};
|
||||
}
|
||||
|
||||
private buildControlArgs(params: { cwd: string; command: string[] }): string[] {
|
||||
return ["--format", "json", "--json-strict", "--cwd", params.cwd, ...params.command];
|
||||
}
|
||||
|
||||
private buildPromptArgs(params: { agent: string; sessionName: string; cwd: string }): string[] {
|
||||
const args = [
|
||||
"--format",
|
||||
"json",
|
||||
"--json-strict",
|
||||
"--cwd",
|
||||
params.cwd,
|
||||
...buildPermissionArgs(this.config.permissionMode),
|
||||
"--non-interactive-permissions",
|
||||
this.config.nonInteractivePermissions,
|
||||
];
|
||||
if (this.config.timeoutSeconds) {
|
||||
args.push("--timeout", String(this.config.timeoutSeconds));
|
||||
}
|
||||
args.push("--ttl", String(this.queueOwnerTtlSeconds));
|
||||
args.push(params.agent, "prompt", "--session", params.sessionName, "--file", "-");
|
||||
return args;
|
||||
}
|
||||
|
||||
private async runControlCommand(params: {
|
||||
args: string[];
|
||||
cwd: string;
|
||||
fallbackCode: AcpRuntimeErrorCode;
|
||||
ignoreNoSession?: boolean;
|
||||
}): Promise<AcpxJsonObject[]> {
|
||||
const result = await spawnAndCollect({
|
||||
command: this.config.command,
|
||||
args: params.args,
|
||||
cwd: params.cwd,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
const spawnFailure = resolveSpawnFailure(result.error, params.cwd);
|
||||
if (spawnFailure === "missing-command") {
|
||||
this.healthy = false;
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_BACKEND_UNAVAILABLE",
|
||||
`acpx command not found: ${this.config.command}`,
|
||||
{ cause: result.error },
|
||||
);
|
||||
}
|
||||
if (spawnFailure === "missing-cwd") {
|
||||
throw new AcpRuntimeError(
|
||||
params.fallbackCode,
|
||||
`ACP runtime working directory does not exist: ${params.cwd}`,
|
||||
{ cause: result.error },
|
||||
);
|
||||
}
|
||||
throw new AcpRuntimeError(params.fallbackCode, result.error.message, { cause: result.error });
|
||||
}
|
||||
|
||||
const events = parseJsonLines(result.stdout);
|
||||
const errorEvent = events.map((event) => toAcpxErrorEvent(event)).find(Boolean) ?? null;
|
||||
if (errorEvent) {
|
||||
if (params.ignoreNoSession && errorEvent.code === "NO_SESSION") {
|
||||
return events;
|
||||
}
|
||||
throw new AcpRuntimeError(
|
||||
params.fallbackCode,
|
||||
errorEvent.code ? `${errorEvent.code}: ${errorEvent.message}` : errorEvent.message,
|
||||
);
|
||||
}
|
||||
|
||||
if ((result.code ?? 0) !== 0) {
|
||||
throw new AcpRuntimeError(
|
||||
params.fallbackCode,
|
||||
result.stderr.trim() || `acpx exited with code ${result.code ?? "unknown"}`,
|
||||
);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
}
|
||||
173
openclaw/extensions/acpx/src/service.test.ts
Normal file
173
openclaw/extensions/acpx/src/service.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { AcpRuntime, OpenClawPluginServiceContext } from "openclaw/plugin-sdk";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AcpRuntimeError } from "../../../src/acp/runtime/errors.js";
|
||||
import {
|
||||
__testing,
|
||||
getAcpRuntimeBackend,
|
||||
requireAcpRuntimeBackend,
|
||||
} from "../../../src/acp/runtime/registry.js";
|
||||
import { ACPX_BUNDLED_BIN } from "./config.js";
|
||||
import { createAcpxRuntimeService } from "./service.js";
|
||||
|
||||
const { ensurePinnedAcpxSpy } = vi.hoisted(() => ({
|
||||
ensurePinnedAcpxSpy: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("./ensure.js", () => ({
|
||||
ensurePinnedAcpx: ensurePinnedAcpxSpy,
|
||||
}));
|
||||
|
||||
type RuntimeStub = AcpRuntime & {
|
||||
probeAvailability(): Promise<void>;
|
||||
isHealthy(): boolean;
|
||||
};
|
||||
|
||||
function createRuntimeStub(healthy: boolean): {
|
||||
runtime: RuntimeStub;
|
||||
probeAvailabilitySpy: ReturnType<typeof vi.fn>;
|
||||
isHealthySpy: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const probeAvailabilitySpy = vi.fn(async () => {});
|
||||
const isHealthySpy = vi.fn(() => healthy);
|
||||
return {
|
||||
runtime: {
|
||||
ensureSession: vi.fn(async (input) => ({
|
||||
sessionKey: input.sessionKey,
|
||||
backend: "acpx",
|
||||
runtimeSessionName: input.sessionKey,
|
||||
})),
|
||||
runTurn: vi.fn(async function* () {
|
||||
yield { type: "done" as const };
|
||||
}),
|
||||
cancel: vi.fn(async () => {}),
|
||||
close: vi.fn(async () => {}),
|
||||
async probeAvailability() {
|
||||
await probeAvailabilitySpy();
|
||||
},
|
||||
isHealthy() {
|
||||
return isHealthySpy();
|
||||
},
|
||||
},
|
||||
probeAvailabilitySpy,
|
||||
isHealthySpy,
|
||||
};
|
||||
}
|
||||
|
||||
function createServiceContext(
|
||||
overrides: Partial<OpenClawPluginServiceContext> = {},
|
||||
): OpenClawPluginServiceContext {
|
||||
return {
|
||||
config: {},
|
||||
workspaceDir: "/tmp/workspace",
|
||||
stateDir: "/tmp/state",
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("createAcpxRuntimeService", () => {
|
||||
beforeEach(() => {
|
||||
__testing.resetAcpRuntimeBackendsForTests();
|
||||
ensurePinnedAcpxSpy.mockReset();
|
||||
ensurePinnedAcpxSpy.mockImplementation(async () => {});
|
||||
});
|
||||
|
||||
it("registers and unregisters the acpx backend", async () => {
|
||||
const { runtime, probeAvailabilitySpy } = createRuntimeStub(true);
|
||||
const service = createAcpxRuntimeService({
|
||||
runtimeFactory: () => runtime,
|
||||
});
|
||||
const context = createServiceContext();
|
||||
|
||||
await service.start(context);
|
||||
expect(getAcpRuntimeBackend("acpx")?.runtime).toBe(runtime);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(ensurePinnedAcpxSpy).toHaveBeenCalledOnce();
|
||||
expect(probeAvailabilitySpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
await service.stop?.(context);
|
||||
expect(getAcpRuntimeBackend("acpx")).toBeNull();
|
||||
});
|
||||
|
||||
it("marks backend unavailable when runtime health check fails", async () => {
|
||||
const { runtime } = createRuntimeStub(false);
|
||||
const service = createAcpxRuntimeService({
|
||||
runtimeFactory: () => runtime,
|
||||
});
|
||||
const context = createServiceContext();
|
||||
|
||||
await service.start(context);
|
||||
|
||||
expect(() => requireAcpRuntimeBackend("acpx")).toThrowError(AcpRuntimeError);
|
||||
try {
|
||||
requireAcpRuntimeBackend("acpx");
|
||||
throw new Error("expected ACP backend lookup to fail");
|
||||
} catch (error) {
|
||||
expect((error as AcpRuntimeError).code).toBe("ACP_BACKEND_UNAVAILABLE");
|
||||
}
|
||||
});
|
||||
|
||||
it("passes queue-owner TTL from plugin config", async () => {
|
||||
const { runtime } = createRuntimeStub(true);
|
||||
const runtimeFactory = vi.fn(() => runtime);
|
||||
const service = createAcpxRuntimeService({
|
||||
runtimeFactory,
|
||||
pluginConfig: {
|
||||
queueOwnerTtlSeconds: 0.25,
|
||||
},
|
||||
});
|
||||
const context = createServiceContext();
|
||||
|
||||
await service.start(context);
|
||||
|
||||
expect(runtimeFactory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queueOwnerTtlSeconds: 0.25,
|
||||
pluginConfig: expect.objectContaining({
|
||||
command: ACPX_BUNDLED_BIN,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses a short default queue-owner TTL", async () => {
|
||||
const { runtime } = createRuntimeStub(true);
|
||||
const runtimeFactory = vi.fn(() => runtime);
|
||||
const service = createAcpxRuntimeService({
|
||||
runtimeFactory,
|
||||
});
|
||||
const context = createServiceContext();
|
||||
|
||||
await service.start(context);
|
||||
|
||||
expect(runtimeFactory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queueOwnerTtlSeconds: 0.1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not block startup while acpx ensure runs", async () => {
|
||||
const { runtime } = createRuntimeStub(true);
|
||||
ensurePinnedAcpxSpy.mockImplementation(() => new Promise<void>(() => {}));
|
||||
const service = createAcpxRuntimeService({
|
||||
runtimeFactory: () => runtime,
|
||||
});
|
||||
const context = createServiceContext();
|
||||
|
||||
const startResult = await Promise.race([
|
||||
Promise.resolve(service.start(context)).then(() => "started"),
|
||||
new Promise<string>((resolve) => setTimeout(() => resolve("timed_out"), 100)),
|
||||
]);
|
||||
|
||||
expect(startResult).toBe("started");
|
||||
expect(getAcpRuntimeBackend("acpx")?.runtime).toBe(runtime);
|
||||
});
|
||||
});
|
||||
102
openclaw/extensions/acpx/src/service.ts
Normal file
102
openclaw/extensions/acpx/src/service.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type {
|
||||
AcpRuntime,
|
||||
OpenClawPluginService,
|
||||
OpenClawPluginServiceContext,
|
||||
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 { ACPX_BACKEND_ID, AcpxRuntime } from "./runtime.js";
|
||||
|
||||
type AcpxRuntimeLike = AcpRuntime & {
|
||||
probeAvailability(): Promise<void>;
|
||||
isHealthy(): boolean;
|
||||
};
|
||||
|
||||
type AcpxRuntimeFactoryParams = {
|
||||
pluginConfig: ResolvedAcpxPluginConfig;
|
||||
queueOwnerTtlSeconds: number;
|
||||
logger?: PluginLogger;
|
||||
};
|
||||
|
||||
type CreateAcpxRuntimeServiceParams = {
|
||||
pluginConfig?: unknown;
|
||||
runtimeFactory?: (params: AcpxRuntimeFactoryParams) => AcpxRuntimeLike;
|
||||
};
|
||||
|
||||
function createDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntimeLike {
|
||||
return new AcpxRuntime(params.pluginConfig, {
|
||||
logger: params.logger,
|
||||
queueOwnerTtlSeconds: params.queueOwnerTtlSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
export function createAcpxRuntimeService(
|
||||
params: CreateAcpxRuntimeServiceParams = {},
|
||||
): OpenClawPluginService {
|
||||
let runtime: AcpxRuntimeLike | null = null;
|
||||
let lifecycleRevision = 0;
|
||||
|
||||
return {
|
||||
id: "acpx-runtime",
|
||||
async start(ctx: OpenClawPluginServiceContext): Promise<void> {
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: params.pluginConfig,
|
||||
workspaceDir: ctx.workspaceDir,
|
||||
});
|
||||
const runtimeFactory = params.runtimeFactory ?? createDefaultRuntime;
|
||||
runtime = runtimeFactory({
|
||||
pluginConfig,
|
||||
queueOwnerTtlSeconds: pluginConfig.queueOwnerTtlSeconds,
|
||||
logger: ctx.logger,
|
||||
});
|
||||
|
||||
registerAcpRuntimeBackend({
|
||||
id: ACPX_BACKEND_ID,
|
||||
runtime,
|
||||
healthy: () => runtime?.isHealthy() ?? false,
|
||||
});
|
||||
ctx.logger.info(
|
||||
`acpx runtime backend registered (command: ${pluginConfig.command}, pinned: ${ACPX_PINNED_VERSION})`,
|
||||
);
|
||||
|
||||
lifecycleRevision += 1;
|
||||
const currentRevision = lifecycleRevision;
|
||||
void (async () => {
|
||||
try {
|
||||
await ensurePinnedAcpx({
|
||||
command: pluginConfig.command,
|
||||
logger: ctx.logger,
|
||||
expectedVersion: ACPX_PINNED_VERSION,
|
||||
});
|
||||
if (currentRevision !== lifecycleRevision) {
|
||||
return;
|
||||
}
|
||||
await runtime?.probeAvailability();
|
||||
if (runtime?.isHealthy()) {
|
||||
ctx.logger.info("acpx runtime backend ready");
|
||||
} else {
|
||||
ctx.logger.warn("acpx runtime backend probe failed after local install");
|
||||
}
|
||||
} catch (err) {
|
||||
if (currentRevision !== lifecycleRevision) {
|
||||
return;
|
||||
}
|
||||
ctx.logger.warn(
|
||||
`acpx runtime setup failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
})();
|
||||
},
|
||||
async stop(_ctx: OpenClawPluginServiceContext): Promise<void> {
|
||||
lifecycleRevision += 1;
|
||||
unregisterAcpRuntimeBackend(ACPX_BACKEND_ID);
|
||||
runtime = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user