mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-14 13:43:17 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
54
openclaw/extensions/acpx/AGENTS.md
Normal file
54
openclaw/extensions/acpx/AGENTS.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# ACPX Extension Notes
|
||||
|
||||
This file applies to work under `extensions/acpx/`.
|
||||
|
||||
## Purpose
|
||||
|
||||
The bundled ACPX extension is a thin OpenClaw wrapper around the published `acpx` package. Keep reusable ACP runtime logic in `openclaw/acpx`, not in this extension.
|
||||
|
||||
## Default Version Policy
|
||||
|
||||
- `extensions/acpx/package.json` should point at a published npm release by default.
|
||||
- Do not leave the extension pinned to a temporary GitHub commit or local checkout once the ACPX release exists.
|
||||
- Do not leave temporary pnpm build-script allowlist exceptions behind after switching back to a published ACPX package.
|
||||
|
||||
## Unreleased ACPX Development Flow
|
||||
|
||||
Use this flow when OpenClaw needs unreleased ACPX changes before the ACPX version is published.
|
||||
|
||||
1. Make the ACPX code change in the `openclaw/acpx` repo first.
|
||||
2. In OpenClaw, temporarily point `extensions/acpx/package.json` at the ACPX GitHub commit you need.
|
||||
3. If pnpm blocks ACPX lifecycle/build scripts for that temporary GitHub-sourced package, temporarily add `acpx` to `onlyBuiltDependencies` in both `package.json` and `pnpm-workspace.yaml`.
|
||||
4. Refresh the root workspace lock:
|
||||
- `pnpm install --lockfile-only --filter ./extensions/acpx`
|
||||
5. Refresh the extension-local npm lock for install metadata:
|
||||
- `cd extensions/acpx && npm install --package-lock-only --ignore-scripts`
|
||||
6. Rebuild OpenClaw and restart the gateway before doing live ACP validation.
|
||||
7. Once ACPX is released, switch `extensions/acpx/package.json` back to the published npm version and refresh the same lockfiles again.
|
||||
8. Remove any temporary `acpx` build-script allowlist entries that were only needed for the GitHub-sourced development pin.
|
||||
|
||||
## Lockfile Notes
|
||||
|
||||
- `pnpm-lock.yaml` is the tracked workspace lockfile and must match the ACPX version referenced by `extensions/acpx/package.json`.
|
||||
- `extensions/acpx/package-lock.json` is useful local install metadata for the bundled plugin package.
|
||||
- If `extensions/acpx/package-lock.json` is gitignored in this repo state, regenerating it is still useful for local verification, but it will not appear in `git status`.
|
||||
|
||||
## Local Runtime Validation
|
||||
|
||||
When ACPX integration changes here, prefer this sequence:
|
||||
|
||||
1. `pnpm install --filter ./extensions/acpx`
|
||||
2. `pnpm test:extension acpx`
|
||||
3. `pnpm build`
|
||||
4. Restart the local gateway if ACP runtime behavior or bundled plugin wiring changed.
|
||||
5. If the change affects direct ACP behavior in chat, run a real ACP smoke after restart.
|
||||
|
||||
## Direct ACPX Binary Policy
|
||||
|
||||
- Prefer the plugin-local ACPX binary under `extensions/acpx/node_modules/.bin/acpx`.
|
||||
- Do not rely on a globally installed `acpx` binary for OpenClaw ACP validation.
|
||||
- If the plugin-local ACPX binary is missing or on the wrong version, reinstall it from the version pinned in `extensions/acpx/package.json`.
|
||||
|
||||
## Boundary Rule
|
||||
|
||||
If a change feels like shared ACP runtime behavior instead of OpenClaw-specific glue, move it to `openclaw/acpx` and consume it from here instead of re-implementing it inside `extensions/acpx`.
|
||||
1
openclaw/extensions/acpx/CLAUDE.md
Normal file
1
openclaw/extensions/acpx/CLAUDE.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
AGENTS.md
|
||||
41
openclaw/extensions/acpx/index.test.ts
Normal file
41
openclaw/extensions/acpx/index.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { createAcpxRuntimeServiceMock, tryDispatchAcpReplyHookMock } = vi.hoisted(() => ({
|
||||
createAcpxRuntimeServiceMock: vi.fn(),
|
||||
tryDispatchAcpReplyHookMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./register.runtime.js", () => ({
|
||||
createAcpxRuntimeService: createAcpxRuntimeServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock("./runtime-api.js", () => ({
|
||||
tryDispatchAcpReplyHook: tryDispatchAcpReplyHookMock,
|
||||
}));
|
||||
|
||||
import plugin from "./index.js";
|
||||
|
||||
describe("acpx plugin", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("registers the runtime service and reply_dispatch hook", () => {
|
||||
const service = { id: "acpx-service", start: vi.fn() };
|
||||
createAcpxRuntimeServiceMock.mockReturnValue(service);
|
||||
|
||||
const api = {
|
||||
pluginConfig: { stateDir: "/tmp/acpx" },
|
||||
registerService: vi.fn(),
|
||||
on: vi.fn(),
|
||||
};
|
||||
|
||||
plugin.register(api as never);
|
||||
|
||||
expect(createAcpxRuntimeServiceMock).toHaveBeenCalledWith({
|
||||
pluginConfig: api.pluginConfig,
|
||||
});
|
||||
expect(api.registerService).toHaveBeenCalledWith(service);
|
||||
expect(api.on).toHaveBeenCalledWith("reply_dispatch", tryDispatchAcpReplyHookMock);
|
||||
});
|
||||
});
|
||||
20
openclaw/extensions/acpx/index.ts
Normal file
20
openclaw/extensions/acpx/index.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { createAcpxRuntimeService } from "./register.runtime.js";
|
||||
import { tryDispatchAcpReplyHook, type OpenClawPluginApi } from "./runtime-api.js";
|
||||
import { createAcpxPluginConfigSchema } from "./src/config-schema.js";
|
||||
|
||||
const plugin = {
|
||||
id: "acpx",
|
||||
name: "ACPX Runtime",
|
||||
description: "Embedded ACP runtime backend with plugin-owned session and transport management.",
|
||||
configSchema: () => createAcpxPluginConfigSchema(),
|
||||
register(api: OpenClawPluginApi) {
|
||||
api.registerService(
|
||||
createAcpxRuntimeService({
|
||||
pluginConfig: api.pluginConfig,
|
||||
}),
|
||||
);
|
||||
api.on("reply_dispatch", tryDispatchAcpReplyHook);
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
146
openclaw/extensions/acpx/openclaw.plugin.json
Normal file
146
openclaw/extensions/acpx/openclaw.plugin.json
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
{
|
||||
"id": "acpx",
|
||||
"enabledByDefault": true,
|
||||
"name": "ACPX Runtime",
|
||||
"description": "Embedded ACP runtime backend with plugin-owned session and transport management.",
|
||||
"skills": ["./skills"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"cwd": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"stateDir": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"permissionMode": {
|
||||
"type": "string",
|
||||
"enum": ["approve-all", "approve-reads", "deny-all"]
|
||||
},
|
||||
"nonInteractivePermissions": {
|
||||
"type": "string",
|
||||
"enum": ["deny", "fail"]
|
||||
},
|
||||
"pluginToolsMcpBridge": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"strictWindowsCmdWrapper": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"timeoutSeconds": {
|
||||
"type": "number",
|
||||
"minimum": 0.001,
|
||||
"default": 120
|
||||
},
|
||||
"queueOwnerTtlSeconds": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"mcpServers": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Command to run the MCP server"
|
||||
},
|
||||
"args": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Arguments to pass to the command"
|
||||
},
|
||||
"env": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" },
|
||||
"description": "Environment variables for the MCP server"
|
||||
}
|
||||
},
|
||||
"required": ["command"]
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": ["command"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"uiHints": {
|
||||
"cwd": {
|
||||
"label": "Default Working Directory",
|
||||
"help": "Default working directory for embedded ACP session operations when not set per session."
|
||||
},
|
||||
"stateDir": {
|
||||
"label": "State Directory",
|
||||
"help": "Directory used for embedded ACP session state and persistence."
|
||||
},
|
||||
"permissionMode": {
|
||||
"label": "Permission Mode",
|
||||
"help": "Default permission policy for embedded ACP runtime prompts."
|
||||
},
|
||||
"nonInteractivePermissions": {
|
||||
"label": "Non-Interactive Permission Policy",
|
||||
"help": "Policy when interactive permission prompts are unavailable."
|
||||
},
|
||||
"pluginToolsMcpBridge": {
|
||||
"label": "Plugin Tools MCP Bridge",
|
||||
"help": "Default off. When enabled, inject the built-in OpenClaw plugin-tools MCP server into embedded ACP sessions so ACP agents can call plugin-registered tools.",
|
||||
"advanced": true
|
||||
},
|
||||
"strictWindowsCmdWrapper": {
|
||||
"label": "Strict Windows cmd Wrapper",
|
||||
"help": "Legacy compatibility field. The current embedded acpx/runtime package uses its own Windows command resolution behavior. Setting this to false is accepted for compatibility and logged as ignored.",
|
||||
"advanced": true
|
||||
},
|
||||
"timeoutSeconds": {
|
||||
"label": "Prompt Timeout Seconds",
|
||||
"help": "Timeout for each embedded runtime turn. Defaults to 120 seconds so slower Gemini CLI ACP startups have room to initialize.",
|
||||
"advanced": true
|
||||
},
|
||||
"queueOwnerTtlSeconds": {
|
||||
"label": "Queue Owner TTL Seconds",
|
||||
"help": "Reserved compatibility field for the older embedded ACPX queue-owner path. Accepted for compatibility and logged as ignored.",
|
||||
"advanced": true
|
||||
},
|
||||
"mcpServers": {
|
||||
"label": "MCP Servers",
|
||||
"help": "Named MCP server definitions to inject into embedded ACP session bootstrap. Each entry needs a command and can include args and env.",
|
||||
"advanced": true
|
||||
},
|
||||
"agents": {
|
||||
"label": "Agent Commands",
|
||||
"help": "Optional per-agent command overrides for the embedded ACP runtime.",
|
||||
"advanced": true
|
||||
}
|
||||
},
|
||||
"configContracts": {
|
||||
"dangerousFlags": [
|
||||
{
|
||||
"path": "permissionMode",
|
||||
"equals": "approve-all"
|
||||
}
|
||||
],
|
||||
"secretInputs": {
|
||||
"bundledDefaultEnabled": false,
|
||||
"paths": [
|
||||
{
|
||||
"path": "mcpServers.*.env.*",
|
||||
"expected": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
17
openclaw/extensions/acpx/package.json
Normal file
17
openclaw/extensions/acpx/package.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "@openclaw/acpx",
|
||||
"version": "2026.4.20",
|
||||
"description": "OpenClaw ACP runtime backend",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"acpx": "0.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
1
openclaw/extensions/acpx/register.runtime.ts
Normal file
1
openclaw/extensions/acpx/register.runtime.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { createAcpxRuntimeService } from "./src/service.js";
|
||||
46
openclaw/extensions/acpx/runtime-api.ts
Normal file
46
openclaw/extensions/acpx/runtime-api.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
export type { AcpRuntimeErrorCode } from "openclaw/plugin-sdk/acp-runtime";
|
||||
export {
|
||||
AcpRuntimeError,
|
||||
getAcpRuntimeBackend,
|
||||
tryDispatchAcpReplyHook,
|
||||
registerAcpRuntimeBackend,
|
||||
unregisterAcpRuntimeBackend,
|
||||
} from "openclaw/plugin-sdk/acp-runtime";
|
||||
export type {
|
||||
AcpRuntime,
|
||||
AcpRuntimeCapabilities,
|
||||
AcpRuntimeDoctorReport,
|
||||
AcpRuntimeEnsureInput,
|
||||
AcpRuntimeEvent,
|
||||
AcpRuntimeHandle,
|
||||
AcpRuntimeStatus,
|
||||
AcpRuntimeTurnAttachment,
|
||||
AcpRuntimeTurnInput,
|
||||
AcpSessionUpdateTag,
|
||||
} from "openclaw/plugin-sdk/acp-runtime";
|
||||
export type {
|
||||
OpenClawPluginApi,
|
||||
OpenClawPluginConfigSchema,
|
||||
OpenClawPluginService,
|
||||
OpenClawPluginServiceContext,
|
||||
PluginLogger,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export type {
|
||||
PluginHookReplyDispatchContext,
|
||||
PluginHookReplyDispatchEvent,
|
||||
PluginHookReplyDispatchResult,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export type {
|
||||
WindowsSpawnProgram,
|
||||
WindowsSpawnProgramCandidate,
|
||||
WindowsSpawnResolution,
|
||||
} from "openclaw/plugin-sdk/windows-spawn";
|
||||
export {
|
||||
applyWindowsSpawnProgramPolicy,
|
||||
materializeWindowsSpawnProgram,
|
||||
resolveWindowsSpawnProgramCandidate,
|
||||
} from "openclaw/plugin-sdk/windows-spawn";
|
||||
export {
|
||||
listKnownProviderAuthEnvVarNames,
|
||||
omitEnvKeysCaseInsensitive,
|
||||
} from "openclaw/plugin-sdk/provider-env-vars";
|
||||
18
openclaw/extensions/acpx/setup-api.ts
Normal file
18
openclaw/extensions/acpx/setup-api.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "acpx",
|
||||
name: "ACPX Setup",
|
||||
description: "Lightweight ACPX setup hooks",
|
||||
register(api) {
|
||||
api.registerAutoEnableProbe(({ config }) => {
|
||||
const backendRaw = normalizeLowercaseStringOrEmpty(config.acp?.backend);
|
||||
const configured =
|
||||
config.acp?.enabled === true ||
|
||||
config.acp?.dispatch?.enabled === true ||
|
||||
backendRaw === "acpx";
|
||||
return configured && (!backendRaw || backendRaw === "acpx") ? "ACP runtime configured" : null;
|
||||
});
|
||||
},
|
||||
});
|
||||
245
openclaw/extensions/acpx/skills/acp-router/SKILL.md
Normal file
245
openclaw/extensions/acpx/skills/acp-router/SKILL.md
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
---
|
||||
name: acp-router
|
||||
description: Route plain-language requests for Pi, Claude Code, Codex, Cursor, Copilot, OpenClaw ACP, OpenCode, Gemini CLI, Qwen, Kiro, Kimi, iFlow, Factory Droid, Kilocode, or ACP harness work into either OpenClaw ACP runtime sessions or direct acpx-driven sessions ("telephone game" flow). For coding-agent thread requests, read this skill first, then use only `sessions_spawn` for thread creation.
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# ACP Harness Router
|
||||
|
||||
When user intent is "run this in Pi/Claude Code/Codex/Cursor/Copilot/OpenClaw/OpenCode/Gemini/Qwen/Kiro/Kimi/iFlow/Droid/Kilocode (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 / Cursor / Copilot / OpenClaw / OpenCode / Gemini / Qwen / Kiro / Kimi / iFlow / Droid / Kilocode
|
||||
- continue existing harness work
|
||||
- relay instructions to an external coding harness
|
||||
- keep an external harness conversation in a thread-like conversation
|
||||
|
||||
Mandatory preflight for coding-agent thread requests:
|
||||
|
||||
- Before creating any thread for ACP harness work, read this skill first in the same turn.
|
||||
- After reading, follow `OpenClaw ACP runtime path` below; do not use `message(action="thread-create")` for ACP harness thread spawn.
|
||||
|
||||
## 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 supported ACP harness CLIs when `acpx` is available
|
||||
|
||||
## AgentId mapping
|
||||
|
||||
Use these defaults when user names a harness directly:
|
||||
|
||||
- "pi" -> `agentId: "pi"`
|
||||
- "openclaw" -> `agentId: "openclaw"`
|
||||
- "claude" or "claude code" -> `agentId: "claude"`
|
||||
- "codex" -> `agentId: "codex"`
|
||||
- "copilot" or "github copilot" -> `agentId: "copilot"`
|
||||
- "cursor" or "cursor cli" -> `agentId: "cursor"`
|
||||
- "droid" or "factory droid" -> `agentId: "droid"`
|
||||
- "opencode" -> `agentId: "opencode"`
|
||||
- "gemini" or "gemini cli" -> `agentId: "gemini"`
|
||||
- "iflow" -> `agentId: "iflow"`
|
||||
- "kilocode" -> `agentId: "kilocode"`
|
||||
- "kimi" or "kimi cli" -> `agentId: "kimi"`
|
||||
- "kiro" or "kiro cli" -> `agentId: "kiro"`
|
||||
- "qwen" or "qwen code" -> `agentId: "qwen"`
|
||||
|
||||
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. For ACP harness thread spawn requests, read this skill first in the same turn before calling tools.
|
||||
2. Use `sessions_spawn` with:
|
||||
- `runtime: "acp"`
|
||||
- `thread: true`
|
||||
- `mode: "session"` (unless user explicitly wants one-shot)
|
||||
3. For ACP harness thread creation, do not use `message` with `action=thread-create`; `sessions_spawn` is the only thread-create path.
|
||||
4. Put requested work in `task` so the ACP session gets it immediately.
|
||||
5. Set `agentId` explicitly unless ACP default agent is known.
|
||||
6. 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, 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 the bundled ACPX plugin package
|
||||
- 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 package.
|
||||
|
||||
1. Prefer plugin-local binary, not global PATH:
|
||||
- `${ACPX_PLUGIN_ROOT}/node_modules/.bin/acpx`
|
||||
2. Resolve pinned version from extension dependency:
|
||||
- `node -e "console.log(require(process.env.ACPX_PLUGIN_ROOT + '/package.json').dependencies.acpx)"`
|
||||
3. If binary is missing or version mismatched, install plugin-local pinned version:
|
||||
- `cd "$ACPX_PLUGIN_ROOT" && npm install --omit=dev --no-save acpx@<pinnedVersion>`
|
||||
4. Verify before use:
|
||||
- `${ACPX_PLUGIN_ROOT}/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_PLUGIN_ROOT="<bundled-acpx-plugin-root>"
|
||||
ACPX_CMD="$ACPX_PLUGIN_ROOT/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
|
||||
|
||||
- `claude`
|
||||
- `codex`
|
||||
- `copilot`
|
||||
- `cursor`
|
||||
- `droid`
|
||||
- `gemini`
|
||||
- `iflow`
|
||||
- `kilocode`
|
||||
- `kimi`
|
||||
- `kiro`
|
||||
- `openclaw`
|
||||
- `opencode`
|
||||
- `pi`
|
||||
- `qwen`
|
||||
|
||||
### Built-in adapter commands in acpx
|
||||
|
||||
Defaults are:
|
||||
|
||||
- `openclaw -> openclaw acp`
|
||||
- `claude -> npx -y @zed-industries/claude-agent-acp@0.21.0`
|
||||
- `codex -> npx @zed-industries/codex-acp@^0.9.5`
|
||||
- `copilot -> copilot --acp --stdio`
|
||||
- `cursor -> cursor-agent acp`
|
||||
- `droid -> droid exec --output-format acp`
|
||||
- `gemini -> gemini --acp`
|
||||
- `iflow -> iflow --experimental-acp`
|
||||
- `kilocode -> npx -y @kilocode/cli acp`
|
||||
- `kimi -> kimi acp`
|
||||
- `kiro -> kiro-cli acp`
|
||||
- `opencode -> npx -y opencode-ai acp`
|
||||
- `pi -> npx pi-acp@^0.0.22`
|
||||
- `qwen -> qwen --acp`
|
||||
|
||||
If `~/.acpx/config.json` overrides `agents`, those overrides replace defaults.
|
||||
If your local Cursor install still exposes ACP as `agent acp`, set that as the `cursor` agent override explicitly.
|
||||
|
||||
### Failure handling
|
||||
|
||||
- `acpx: command not found`:
|
||||
- for thread-spawn ACP requests, install plugin-local pinned acpx in the bundled ACPX plugin package 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.
|
||||
60
openclaw/extensions/acpx/src/acpx-runtime-compat.d.ts
vendored
Normal file
60
openclaw/extensions/acpx/src/acpx-runtime-compat.d.ts
vendored
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
declare module "acpx/runtime" {
|
||||
export const ACPX_BACKEND_ID: string;
|
||||
|
||||
export type AcpRuntimeDoctorReport = import("../runtime-api.js").AcpRuntimeDoctorReport;
|
||||
export type AcpRuntimeEnsureInput = import("../runtime-api.js").AcpRuntimeEnsureInput;
|
||||
export type AcpRuntimeEvent = import("../runtime-api.js").AcpRuntimeEvent;
|
||||
export type AcpRuntimeHandle = import("../runtime-api.js").AcpRuntimeHandle;
|
||||
export type AcpRuntimeCapabilities = import("../runtime-api.js").AcpRuntimeCapabilities;
|
||||
export type AcpRuntimeStatus = import("../runtime-api.js").AcpRuntimeStatus;
|
||||
export type AcpRuntimeTurnInput = import("../runtime-api.js").AcpRuntimeTurnInput;
|
||||
|
||||
export type AcpAgentRegistry = {
|
||||
resolve(agent: string): string | undefined;
|
||||
list(): string[];
|
||||
};
|
||||
|
||||
export type AcpSessionRecord = Record<string, unknown>;
|
||||
|
||||
export type AcpSessionStore = {
|
||||
load(sessionId: string): Promise<AcpSessionRecord | undefined>;
|
||||
save(record: AcpSessionRecord): Promise<void>;
|
||||
};
|
||||
|
||||
export type AcpRuntimeOptions = {
|
||||
cwd: string;
|
||||
sessionStore: AcpSessionStore;
|
||||
agentRegistry: AcpAgentRegistry;
|
||||
mcpServers?: unknown;
|
||||
permissionMode?: unknown;
|
||||
nonInteractivePermissions?: unknown;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export class AcpxRuntime {
|
||||
constructor(options: AcpRuntimeOptions, testOptions?: unknown);
|
||||
isHealthy(): boolean;
|
||||
probeAvailability(): Promise<void>;
|
||||
doctor(): Promise<AcpRuntimeDoctorReport>;
|
||||
ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle>;
|
||||
runTurn(input: AcpRuntimeTurnInput): AsyncIterable<AcpRuntimeEvent>;
|
||||
getCapabilities(input?: {
|
||||
handle?: AcpRuntimeHandle;
|
||||
}): AcpRuntimeCapabilities | Promise<AcpRuntimeCapabilities>;
|
||||
getStatus(input: { handle: AcpRuntimeHandle; signal?: AbortSignal }): Promise<AcpRuntimeStatus>;
|
||||
setMode(input: { handle: AcpRuntimeHandle; mode: string }): Promise<void>;
|
||||
setConfigOption(input: { handle: AcpRuntimeHandle; key: string; value: string }): Promise<void>;
|
||||
cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void>;
|
||||
close(input: {
|
||||
handle: AcpRuntimeHandle;
|
||||
reason?: string;
|
||||
discardPersistentState?: boolean;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
export function createAcpRuntime(...args: unknown[]): AcpxRuntime;
|
||||
export function createAgentRegistry(params: { overrides?: unknown }): AcpAgentRegistry;
|
||||
export function createFileSessionStore(params: { stateDir: string }): AcpSessionStore;
|
||||
export function decodeAcpxRuntimeHandleState(...args: unknown[]): unknown;
|
||||
export function encodeAcpxRuntimeHandleState(...args: unknown[]): unknown;
|
||||
}
|
||||
115
openclaw/extensions/acpx/src/config-schema.ts
Normal file
115
openclaw/extensions/acpx/src/config-schema.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { buildPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
||||
import { z } from "openclaw/plugin-sdk/zod";
|
||||
import type { OpenClawPluginConfigSchema } from "../runtime-api.js";
|
||||
|
||||
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 DEFAULT_ACPX_TIMEOUT_SECONDS = 120;
|
||||
|
||||
export type McpServerConfig = {
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type AcpxMcpServer = {
|
||||
name: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
env: Array<{ name: string; value: string }>;
|
||||
};
|
||||
|
||||
export type AcpxPluginConfig = {
|
||||
cwd?: string;
|
||||
stateDir?: string;
|
||||
permissionMode?: AcpxPermissionMode;
|
||||
nonInteractivePermissions?: AcpxNonInteractivePermissionPolicy;
|
||||
pluginToolsMcpBridge?: boolean;
|
||||
strictWindowsCmdWrapper?: boolean;
|
||||
timeoutSeconds?: number;
|
||||
queueOwnerTtlSeconds?: number;
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
agents?: Record<string, { command: string }>;
|
||||
};
|
||||
|
||||
export type ResolvedAcpxPluginConfig = {
|
||||
cwd: string;
|
||||
stateDir: string;
|
||||
permissionMode: AcpxPermissionMode;
|
||||
nonInteractivePermissions: AcpxNonInteractivePermissionPolicy;
|
||||
pluginToolsMcpBridge: boolean;
|
||||
strictWindowsCmdWrapper: boolean;
|
||||
timeoutSeconds?: number;
|
||||
queueOwnerTtlSeconds: number;
|
||||
legacyCompatibilityConfig: {
|
||||
strictWindowsCmdWrapper?: boolean;
|
||||
queueOwnerTtlSeconds?: number;
|
||||
};
|
||||
mcpServers: Record<string, McpServerConfig>;
|
||||
agents: Record<string, string>;
|
||||
};
|
||||
|
||||
const nonEmptyTrimmedString = (message: string) =>
|
||||
z.string({ error: message }).trim().min(1, { error: message });
|
||||
|
||||
const McpServerConfigSchema = z.object({
|
||||
command: nonEmptyTrimmedString("command must be a non-empty string").describe(
|
||||
"Command to run the MCP server",
|
||||
),
|
||||
args: z
|
||||
.array(z.string({ error: "args must be an array of strings" }), {
|
||||
error: "args must be an array of strings",
|
||||
})
|
||||
.optional()
|
||||
.describe("Arguments to pass to the command"),
|
||||
env: z
|
||||
.record(z.string(), z.string({ error: "env values must be strings" }), {
|
||||
error: "env must be an object of strings",
|
||||
})
|
||||
.optional()
|
||||
.describe("Environment variables for the MCP server"),
|
||||
});
|
||||
|
||||
export const AcpxPluginConfigSchema = z.strictObject({
|
||||
cwd: nonEmptyTrimmedString("cwd must be a non-empty string").optional(),
|
||||
stateDir: nonEmptyTrimmedString("stateDir must be a non-empty string").optional(),
|
||||
permissionMode: z
|
||||
.enum(ACPX_PERMISSION_MODES, {
|
||||
error: `permissionMode must be one of: ${ACPX_PERMISSION_MODES.join(", ")}`,
|
||||
})
|
||||
.optional(),
|
||||
nonInteractivePermissions: z
|
||||
.enum(ACPX_NON_INTERACTIVE_POLICIES, {
|
||||
error: `nonInteractivePermissions must be one of: ${ACPX_NON_INTERACTIVE_POLICIES.join(", ")}`,
|
||||
})
|
||||
.optional(),
|
||||
pluginToolsMcpBridge: z.boolean({ error: "pluginToolsMcpBridge must be a boolean" }).optional(),
|
||||
strictWindowsCmdWrapper: z
|
||||
.boolean({ error: "strictWindowsCmdWrapper must be a boolean" })
|
||||
.optional(),
|
||||
timeoutSeconds: z
|
||||
.number({ error: "timeoutSeconds must be a number >= 0.001" })
|
||||
.min(0.001, { error: "timeoutSeconds must be a number >= 0.001" })
|
||||
.default(DEFAULT_ACPX_TIMEOUT_SECONDS),
|
||||
queueOwnerTtlSeconds: z
|
||||
.number({ error: "queueOwnerTtlSeconds must be a number >= 0" })
|
||||
.min(0, { error: "queueOwnerTtlSeconds must be a number >= 0" })
|
||||
.optional(),
|
||||
mcpServers: z.record(z.string(), McpServerConfigSchema).optional(),
|
||||
agents: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.strictObject({
|
||||
command: nonEmptyTrimmedString("agents.<id>.command must be a non-empty string"),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export function createAcpxPluginConfigSchema(): OpenClawPluginConfigSchema {
|
||||
return buildPluginConfigSchema(AcpxPluginConfigSchema);
|
||||
}
|
||||
85
openclaw/extensions/acpx/src/config.test.ts
Normal file
85
openclaw/extensions/acpx/src/config.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveAcpxPluginConfig, resolveAcpxPluginRoot } from "./config.js";
|
||||
|
||||
describe("embedded acpx plugin config", () => {
|
||||
it("resolves workspace stateDir and cwd by default", () => {
|
||||
const workspaceDir = "/tmp/openclaw-acpx";
|
||||
const resolved = resolveAcpxPluginConfig({
|
||||
rawConfig: undefined,
|
||||
workspaceDir,
|
||||
});
|
||||
|
||||
expect(resolved.cwd).toBe(workspaceDir);
|
||||
expect(resolved.stateDir).toBe(path.join(workspaceDir, "state"));
|
||||
expect(resolved.permissionMode).toBe("approve-reads");
|
||||
expect(resolved.nonInteractivePermissions).toBe("fail");
|
||||
expect(resolved.timeoutSeconds).toBe(120);
|
||||
expect(resolved.agents).toEqual({});
|
||||
});
|
||||
|
||||
it("keeps explicit timeoutSeconds config", () => {
|
||||
const resolved = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
timeoutSeconds: 300,
|
||||
},
|
||||
workspaceDir: "/tmp/openclaw-acpx",
|
||||
});
|
||||
|
||||
expect(resolved.timeoutSeconds).toBe(300);
|
||||
});
|
||||
|
||||
it("accepts agent command overrides", () => {
|
||||
const resolved = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
agents: {
|
||||
claude: { command: "claude --acp" },
|
||||
codex: { command: "codex custom-acp" },
|
||||
},
|
||||
},
|
||||
workspaceDir: "/tmp/openclaw-acpx",
|
||||
});
|
||||
|
||||
expect(resolved.agents).toEqual({
|
||||
claude: "claude --acp",
|
||||
codex: "codex custom-acp",
|
||||
});
|
||||
});
|
||||
|
||||
it("injects the built-in plugin-tools MCP server only when explicitly enabled", () => {
|
||||
const resolved = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
pluginToolsMcpBridge: true,
|
||||
},
|
||||
workspaceDir: "/tmp/openclaw-acpx",
|
||||
});
|
||||
|
||||
const server = resolved.mcpServers["openclaw-plugin-tools"];
|
||||
expect(server).toBeDefined();
|
||||
expect(server.command).toBe(process.execPath);
|
||||
expect(Array.isArray(server.args)).toBe(true);
|
||||
expect(server.args?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("keeps the runtime json schema in sync with the manifest config schema", () => {
|
||||
const pluginRoot = resolveAcpxPluginRoot();
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(pluginRoot, "openclaw.plugin.json"), "utf8"),
|
||||
) as { configSchema?: unknown };
|
||||
|
||||
expect(manifest.configSchema).toMatchObject({
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: expect.objectContaining({
|
||||
cwd: expect.any(Object),
|
||||
stateDir: expect.any(Object),
|
||||
timeoutSeconds: expect.objectContaining({
|
||||
default: 120,
|
||||
}),
|
||||
agents: expect.any(Object),
|
||||
mcpServers: expect.any(Object),
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
250
openclaw/extensions/acpx/src/config.ts
Normal file
250
openclaw/extensions/acpx/src/config.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { z } from "openclaw/plugin-sdk/zod";
|
||||
import { AcpxPluginConfigSchema, DEFAULT_ACPX_TIMEOUT_SECONDS } from "./config-schema.js";
|
||||
import type {
|
||||
AcpxPluginConfig,
|
||||
AcpxPermissionMode,
|
||||
AcpxNonInteractivePermissionPolicy,
|
||||
McpServerConfig,
|
||||
AcpxMcpServer,
|
||||
ResolvedAcpxPluginConfig,
|
||||
} from "./config-schema.js";
|
||||
export {
|
||||
ACPX_NON_INTERACTIVE_POLICIES,
|
||||
ACPX_PERMISSION_MODES,
|
||||
type AcpxMcpServer,
|
||||
type AcpxNonInteractivePermissionPolicy,
|
||||
type AcpxPermissionMode,
|
||||
type AcpxPluginConfig,
|
||||
type McpServerConfig,
|
||||
type ResolvedAcpxPluginConfig,
|
||||
createAcpxPluginConfigSchema,
|
||||
} from "./config-schema.js";
|
||||
|
||||
export const ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME = "openclaw-plugin-tools";
|
||||
|
||||
function isAcpxPluginRoot(dir: string): boolean {
|
||||
return (
|
||||
fs.existsSync(path.join(dir, "openclaw.plugin.json")) &&
|
||||
fs.existsSync(path.join(dir, "package.json"))
|
||||
);
|
||||
}
|
||||
|
||||
function resolveNearestAcpxPluginRoot(moduleUrl: string): string {
|
||||
let cursor = path.dirname(fileURLToPath(moduleUrl));
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
// Bundled entries live at the plugin root while source files still live under src/.
|
||||
if (isAcpxPluginRoot(cursor)) {
|
||||
return cursor;
|
||||
}
|
||||
const parent = path.dirname(cursor);
|
||||
if (parent === cursor) {
|
||||
break;
|
||||
}
|
||||
cursor = parent;
|
||||
}
|
||||
return path.resolve(path.dirname(fileURLToPath(moduleUrl)), "..");
|
||||
}
|
||||
|
||||
function resolveWorkspaceAcpxPluginRoot(currentRoot: string): string | null {
|
||||
if (
|
||||
path.basename(currentRoot) !== "acpx" ||
|
||||
path.basename(path.dirname(currentRoot)) !== "extensions" ||
|
||||
path.basename(path.dirname(path.dirname(currentRoot))) !== "dist"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const workspaceRoot = path.resolve(currentRoot, "..", "..", "..", "extensions", "acpx");
|
||||
return isAcpxPluginRoot(workspaceRoot) ? workspaceRoot : null;
|
||||
}
|
||||
|
||||
function resolveRepoAcpxPluginRoot(currentRoot: string): string | null {
|
||||
const workspaceRoot = path.join(currentRoot, "extensions", "acpx");
|
||||
return isAcpxPluginRoot(workspaceRoot) ? workspaceRoot : null;
|
||||
}
|
||||
|
||||
function resolveAcpxPluginRootFromOpenClawLayout(moduleUrl: string): string | null {
|
||||
let cursor = path.dirname(fileURLToPath(moduleUrl));
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
const candidates = [
|
||||
path.join(cursor, "extensions", "acpx"),
|
||||
path.join(cursor, "dist", "extensions", "acpx"),
|
||||
path.join(cursor, "dist-runtime", "extensions", "acpx"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (isAcpxPluginRoot(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const parent = path.dirname(cursor);
|
||||
if (parent === cursor) {
|
||||
break;
|
||||
}
|
||||
cursor = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
export function resolveAcpxPluginRoot(moduleUrl: string = import.meta.url): string {
|
||||
const resolvedRoot = resolveNearestAcpxPluginRoot(moduleUrl);
|
||||
// In a live repo checkout, dist/ can be rebuilt out from under the running gateway.
|
||||
// Prefer the stable source plugin root when a built extension is running beside it.
|
||||
return (
|
||||
resolveWorkspaceAcpxPluginRoot(resolvedRoot) ??
|
||||
resolveRepoAcpxPluginRoot(resolvedRoot) ??
|
||||
// Shared dist/dist-runtime chunks can load this module outside the plugin tree.
|
||||
// Scan common OpenClaw layouts before falling back to the nearest path guess.
|
||||
resolveAcpxPluginRootFromOpenClawLayout(moduleUrl) ??
|
||||
resolvedRoot
|
||||
);
|
||||
}
|
||||
|
||||
export const ACPX_PLUGIN_ROOT = resolveAcpxPluginRoot();
|
||||
|
||||
const DEFAULT_PERMISSION_MODE: AcpxPermissionMode = "approve-reads";
|
||||
const DEFAULT_NON_INTERACTIVE_POLICY: AcpxNonInteractivePermissionPolicy = "fail";
|
||||
const DEFAULT_QUEUE_OWNER_TTL_SECONDS = 0.1;
|
||||
const DEFAULT_STRICT_WINDOWS_CMD_WRAPPER = true;
|
||||
|
||||
type ParseResult =
|
||||
| { ok: true; value: AcpxPluginConfig | undefined }
|
||||
| { ok: false; message: string };
|
||||
|
||||
function formatAcpxConfigIssue(issue: z.ZodIssue | undefined): string {
|
||||
if (!issue) {
|
||||
return "invalid config";
|
||||
}
|
||||
if (issue.code === "unrecognized_keys" && issue.keys.length > 0) {
|
||||
return `unknown config key: ${issue.keys[0]}`;
|
||||
}
|
||||
if (issue.code === "invalid_type" && issue.path.length === 0) {
|
||||
return "expected config object";
|
||||
}
|
||||
return issue.message;
|
||||
}
|
||||
|
||||
function parseAcpxPluginConfig(value: unknown): ParseResult {
|
||||
if (value === undefined) {
|
||||
return { ok: true, value: undefined };
|
||||
}
|
||||
const parsed = AcpxPluginConfigSchema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
return { ok: false, message: formatAcpxConfigIssue(parsed.error.issues[0]) };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
value: parsed.data as AcpxPluginConfig,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOpenClawRoot(currentRoot: string): string {
|
||||
if (
|
||||
path.basename(currentRoot) === "acpx" &&
|
||||
path.basename(path.dirname(currentRoot)) === "extensions"
|
||||
) {
|
||||
const parent = path.dirname(path.dirname(currentRoot));
|
||||
if (path.basename(parent) === "dist") {
|
||||
return path.dirname(parent);
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
return path.resolve(currentRoot, "..");
|
||||
}
|
||||
|
||||
export function resolvePluginToolsMcpServerConfig(
|
||||
moduleUrl: string = import.meta.url,
|
||||
): McpServerConfig {
|
||||
const pluginRoot = resolveAcpxPluginRoot(moduleUrl);
|
||||
const openClawRoot = resolveOpenClawRoot(pluginRoot);
|
||||
const distEntry = path.join(openClawRoot, "dist", "mcp", "plugin-tools-serve.js");
|
||||
if (fs.existsSync(distEntry)) {
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: [distEntry],
|
||||
};
|
||||
}
|
||||
const sourceEntry = path.join(openClawRoot, "src", "mcp", "plugin-tools-serve.ts");
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: ["--import", "tsx", sourceEntry],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveConfiguredMcpServers(params: {
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
pluginToolsMcpBridge: boolean;
|
||||
moduleUrl?: string;
|
||||
}): Record<string, McpServerConfig> {
|
||||
const resolved = { ...params.mcpServers };
|
||||
if (!params.pluginToolsMcpBridge) {
|
||||
return resolved;
|
||||
}
|
||||
if (resolved[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME]) {
|
||||
throw new Error(
|
||||
`mcpServers.${ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME} is reserved when pluginToolsMcpBridge=true`,
|
||||
);
|
||||
}
|
||||
resolved[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME] = resolvePluginToolsMcpServerConfig(params.moduleUrl);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function toAcpMcpServers(mcpServers: Record<string, McpServerConfig>): AcpxMcpServer[] {
|
||||
return Object.entries(mcpServers).map(([name, server]) => ({
|
||||
name,
|
||||
command: server.command,
|
||||
args: [...(server.args ?? [])],
|
||||
env: Object.entries(server.env ?? {}).map(([envName, value]) => ({
|
||||
name: envName,
|
||||
value,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export function resolveAcpxPluginConfig(params: {
|
||||
rawConfig: unknown;
|
||||
workspaceDir?: string;
|
||||
moduleUrl?: string;
|
||||
}): ResolvedAcpxPluginConfig {
|
||||
const parsed = parseAcpxPluginConfig(params.rawConfig);
|
||||
if (!parsed.ok) {
|
||||
throw new Error(parsed.message);
|
||||
}
|
||||
const normalized = parsed.value ?? {};
|
||||
const workspaceDir = params.workspaceDir?.trim() || process.cwd();
|
||||
const fallbackCwd = workspaceDir;
|
||||
const cwd = path.resolve(normalized.cwd?.trim() || fallbackCwd);
|
||||
const stateDir = path.resolve(normalized.stateDir?.trim() || path.join(workspaceDir, "state"));
|
||||
const pluginToolsMcpBridge = normalized.pluginToolsMcpBridge === true;
|
||||
const mcpServers = resolveConfiguredMcpServers({
|
||||
mcpServers: normalized.mcpServers,
|
||||
pluginToolsMcpBridge,
|
||||
moduleUrl: params.moduleUrl,
|
||||
});
|
||||
const agents = Object.fromEntries(
|
||||
Object.entries(normalized.agents ?? {}).map(([name, entry]) => [
|
||||
normalizeLowercaseStringOrEmpty(name),
|
||||
entry.command.trim(),
|
||||
]),
|
||||
);
|
||||
|
||||
return {
|
||||
cwd,
|
||||
stateDir,
|
||||
permissionMode: normalized.permissionMode ?? DEFAULT_PERMISSION_MODE,
|
||||
nonInteractivePermissions:
|
||||
normalized.nonInteractivePermissions ?? DEFAULT_NON_INTERACTIVE_POLICY,
|
||||
pluginToolsMcpBridge,
|
||||
strictWindowsCmdWrapper:
|
||||
normalized.strictWindowsCmdWrapper ?? DEFAULT_STRICT_WINDOWS_CMD_WRAPPER,
|
||||
timeoutSeconds: normalized.timeoutSeconds ?? DEFAULT_ACPX_TIMEOUT_SECONDS,
|
||||
queueOwnerTtlSeconds: normalized.queueOwnerTtlSeconds ?? DEFAULT_QUEUE_OWNER_TTL_SECONDS,
|
||||
legacyCompatibilityConfig: {
|
||||
strictWindowsCmdWrapper: normalized.strictWindowsCmdWrapper,
|
||||
queueOwnerTtlSeconds: normalized.queueOwnerTtlSeconds,
|
||||
},
|
||||
mcpServers,
|
||||
agents,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export function formatErrorMessage(error) {
|
||||
if (error instanceof Error) {
|
||||
return error.message || error.name || "Error";
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
const WINDOWS_DIRECT_EXECUTABLE_PATH_RE =
|
||||
/^(?<command>(?:[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+[\\/]).*?\.(?:exe|com))(?=\s|$)(?:\s+(?<rest>.*))?$/i;
|
||||
|
||||
// Windows wrapper scripts need their host shell or interpreter (`cmd.exe`,
|
||||
// `powershell.exe`, or `node`) instead of direct spawning.
|
||||
const WINDOWS_WRAPPER_PATH_RE =
|
||||
/^(?:[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+[\\/]).*?\.(?:bat|cmd|cjs|js|mjs|ps1)$/i;
|
||||
|
||||
function splitCommandParts(value, platform = process.platform) {
|
||||
const parts = [];
|
||||
let current = "";
|
||||
let quote = null;
|
||||
let escaping = false;
|
||||
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const ch = value[index];
|
||||
const next = value[index + 1];
|
||||
if (escaping) {
|
||||
current += ch;
|
||||
escaping = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\") {
|
||||
if (quote === "'") {
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (platform === "win32") {
|
||||
if (quote === '"') {
|
||||
if (next === '"' || next === "\\") {
|
||||
escaping = true;
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (!quote) {
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
escaping = true;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (ch === quote) {
|
||||
quote = null;
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" || ch === '"') {
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
if (/\s/.test(ch)) {
|
||||
if (current.length > 0) {
|
||||
parts.push(current);
|
||||
current = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
|
||||
if (escaping) {
|
||||
current += "\\";
|
||||
}
|
||||
if (quote) {
|
||||
throw new Error("Invalid agent command: unterminated quote");
|
||||
}
|
||||
if (current.length > 0) {
|
||||
parts.push(current);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function splitWindowsExecutableCommand(value, platform = process.platform) {
|
||||
if (platform !== "win32") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.startsWith('"') || trimmed.startsWith("'")) {
|
||||
return null;
|
||||
}
|
||||
const match = trimmed.match(WINDOWS_DIRECT_EXECUTABLE_PATH_RE);
|
||||
if (!match?.groups?.command) {
|
||||
return null;
|
||||
}
|
||||
const rest = match.groups.rest?.trim() ?? "";
|
||||
return {
|
||||
command: match.groups.command,
|
||||
args: rest ? splitCommandParts(rest, platform) : [],
|
||||
};
|
||||
}
|
||||
|
||||
function assertSupportedWindowsCommand(command, platform = process.platform) {
|
||||
if (platform !== "win32" || !WINDOWS_WRAPPER_PATH_RE.test(command)) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`Unsupported Windows agent command wrapper: ${command}. ` +
|
||||
"Invoke wrapper scripts through their shell or interpreter instead " +
|
||||
"(for example `cmd.exe /c`, `powershell.exe -File`, or `node <script>`).",
|
||||
);
|
||||
}
|
||||
|
||||
export function splitCommandLine(value, platform = process.platform) {
|
||||
const windowsCommand = splitWindowsExecutableCommand(value, platform);
|
||||
const parts = windowsCommand ?? splitCommandParts(value, platform);
|
||||
if (parts.length === 0) {
|
||||
throw new Error("Invalid agent command: empty command");
|
||||
}
|
||||
const parsed = Array.isArray(parts)
|
||||
? {
|
||||
command: parts[0],
|
||||
args: parts.slice(1),
|
||||
}
|
||||
: parts;
|
||||
assertSupportedWindowsCommand(parsed.command, platform);
|
||||
return parsed;
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type SplitCommandLine = (
|
||||
value: string,
|
||||
platform?: string,
|
||||
) => {
|
||||
command: string;
|
||||
args: string[];
|
||||
};
|
||||
|
||||
async function loadSplitCommandLine(): Promise<SplitCommandLine> {
|
||||
const moduleUrl = new URL("./mcp-command-line.mjs", import.meta.url);
|
||||
return (await import(moduleUrl.href)).splitCommandLine as SplitCommandLine;
|
||||
}
|
||||
|
||||
describe("mcp-command-line", () => {
|
||||
it("parses quoted Windows executable paths without dropping backslashes", async () => {
|
||||
const splitCommandLine = await loadSplitCommandLine();
|
||||
const parsed = splitCommandLine(
|
||||
'"C:\\Program Files\\Claude\\claude.exe" --stdio --flag "two words"',
|
||||
"win32",
|
||||
);
|
||||
|
||||
expect(parsed).toEqual({
|
||||
command: "C:\\Program Files\\Claude\\claude.exe",
|
||||
args: ["--stdio", "--flag", "two words"],
|
||||
});
|
||||
});
|
||||
|
||||
it("parses unquoted Windows executable paths without mangling backslashes", async () => {
|
||||
const splitCommandLine = await loadSplitCommandLine();
|
||||
const parsed = splitCommandLine("C:\\Users\\alerl\\.local\\bin\\claude.exe --version", "win32");
|
||||
|
||||
expect(parsed).toEqual({
|
||||
command: "C:\\Users\\alerl\\.local\\bin\\claude.exe",
|
||||
args: ["--version"],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves unquoted Windows path arguments after the executable", async () => {
|
||||
const splitCommandLine = await loadSplitCommandLine();
|
||||
const parsed = splitCommandLine(
|
||||
'"C:\\Program Files\\Claude\\claude.exe" --config C:\\Users\\me\\cfg.json',
|
||||
"win32",
|
||||
);
|
||||
|
||||
expect(parsed).toEqual({
|
||||
command: "C:\\Program Files\\Claude\\claude.exe",
|
||||
args: ["--config", "C:\\Users\\me\\cfg.json"],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects direct Windows wrapper-script commands with a helpful error", async () => {
|
||||
const splitCommandLine = await loadSplitCommandLine();
|
||||
expect(() =>
|
||||
splitCommandLine('"C:\\Users\\me\\bin\\claude-wrapper.cmd" --stdio', "win32"),
|
||||
).toThrow(/Invoke wrapper scripts through their shell or interpreter instead/);
|
||||
});
|
||||
});
|
||||
113
openclaw/extensions/acpx/src/runtime-internals/mcp-proxy.mjs
Normal file
113
openclaw/extensions/acpx/src/runtime-internals/mcp-proxy.mjs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { formatErrorMessage } from "./error-format.mjs";
|
||||
import { splitCommandLine } from "./mcp-command-line.mjs";
|
||||
|
||||
function decodePayload(argv) {
|
||||
const payloadIndex = argv.indexOf("--payload");
|
||||
if (payloadIndex < 0) {
|
||||
throw new Error("Missing --payload");
|
||||
}
|
||||
const encoded = argv[payloadIndex + 1];
|
||||
if (!encoded) {
|
||||
throw new Error("Missing MCP proxy payload value");
|
||||
}
|
||||
const parsed = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("Invalid MCP proxy payload");
|
||||
}
|
||||
if (typeof parsed.targetCommand !== "string" || parsed.targetCommand.trim() === "") {
|
||||
throw new Error("MCP proxy payload missing targetCommand");
|
||||
}
|
||||
const mcpServers = Array.isArray(parsed.mcpServers) ? parsed.mcpServers : [];
|
||||
return {
|
||||
targetCommand: parsed.targetCommand,
|
||||
mcpServers,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldInject(method) {
|
||||
return method === "session/new" || method === "session/load" || method === "session/fork";
|
||||
}
|
||||
|
||||
function rewriteLine(line, mcpServers) {
|
||||
if (!line.trim()) {
|
||||
return line;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== "object" ||
|
||||
Array.isArray(parsed) ||
|
||||
!shouldInject(parsed.method) ||
|
||||
!parsed.params ||
|
||||
typeof parsed.params !== "object" ||
|
||||
Array.isArray(parsed.params)
|
||||
) {
|
||||
return line;
|
||||
}
|
||||
const next = {
|
||||
...parsed,
|
||||
params: {
|
||||
...parsed.params,
|
||||
mcpServers,
|
||||
},
|
||||
};
|
||||
return JSON.stringify(next);
|
||||
} catch {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
|
||||
function isMainModule() {
|
||||
const mainPath = process.argv[1];
|
||||
if (!mainPath) {
|
||||
return false;
|
||||
}
|
||||
return import.meta.url === pathToFileURL(path.resolve(mainPath)).href;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { targetCommand, mcpServers } = decodePayload(process.argv.slice(2));
|
||||
const target = splitCommandLine(targetCommand);
|
||||
const child = spawn(target.command, target.args, {
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
if (!child.stdin || !child.stdout) {
|
||||
throw new Error("Failed to create MCP proxy stdio pipes");
|
||||
}
|
||||
|
||||
const input = createInterface({ input: process.stdin });
|
||||
input.on("line", (line) => {
|
||||
child.stdin.write(`${rewriteLine(line, mcpServers)}\n`);
|
||||
});
|
||||
input.on("close", () => {
|
||||
child.stdin.end();
|
||||
});
|
||||
|
||||
child.stdout.pipe(process.stdout);
|
||||
|
||||
child.on("error", (error) => {
|
||||
process.stderr.write(`${formatErrorMessage(error)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
child.on("close", (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
if (isMainModule()) {
|
||||
main();
|
||||
}
|
||||
114
openclaw/extensions/acpx/src/runtime-internals/mcp-proxy.test.ts
Normal file
114
openclaw/extensions/acpx/src/runtime-internals/mcp-proxy.test.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { bundledPluginFile } from "../../../../test/helpers/bundled-plugin-paths.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const proxyPath = path.resolve(bundledPluginFile("acpx", "src/runtime-internals/mcp-proxy.mjs"));
|
||||
|
||||
async function makeTempScript(name: string, content: string): Promise<string> {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-acpx-mcp-proxy-"));
|
||||
tempDirs.push(dir);
|
||||
const scriptPath = path.join(dir, name);
|
||||
await writeFile(scriptPath, content, "utf8");
|
||||
await chmod(scriptPath, 0o755);
|
||||
return scriptPath;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (!dir) {
|
||||
continue;
|
||||
}
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("mcp-proxy", () => {
|
||||
it("injects configured MCP servers into ACP session bootstrap requests", async () => {
|
||||
const echoServerPath = await makeTempScript(
|
||||
"echo-server.cjs",
|
||||
String.raw`#!/usr/bin/env node
|
||||
const { createInterface } = require("node:readline");
|
||||
const rl = createInterface({ input: process.stdin });
|
||||
rl.on("line", (line) => process.stdout.write(line + "\n"));
|
||||
`,
|
||||
);
|
||||
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({
|
||||
targetCommand: `${process.execPath} ${echoServerPath}`,
|
||||
mcpServers: [
|
||||
{
|
||||
name: "canva",
|
||||
command: "npx",
|
||||
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
|
||||
env: [{ name: "CANVA_TOKEN", value: "secret" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
"utf8",
|
||||
).toString("base64url");
|
||||
|
||||
const child = spawn(process.execPath, [proxyPath, "--payload", payload], {
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
|
||||
child.stdin.write(
|
||||
`${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "session/new",
|
||||
params: { cwd: process.cwd(), mcpServers: [] },
|
||||
})}\n`,
|
||||
);
|
||||
child.stdin.write(
|
||||
`${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "session/load",
|
||||
params: { cwd: process.cwd(), sessionId: "sid-1", mcpServers: [] },
|
||||
})}\n`,
|
||||
);
|
||||
child.stdin.write(
|
||||
`${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "session/prompt",
|
||||
params: { sessionId: "sid-1", prompt: [{ type: "text", text: "hello" }] },
|
||||
})}\n`,
|
||||
);
|
||||
child.stdin.end();
|
||||
|
||||
const exitCode = await new Promise<number | null>((resolve) => {
|
||||
child.once("close", (code) => resolve(code));
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
const lines = stdout
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.map((line) => JSON.parse(line) as { method: string; params: Record<string, unknown> });
|
||||
|
||||
expect(lines[0].params.mcpServers).toEqual([
|
||||
{
|
||||
name: "canva",
|
||||
command: "npx",
|
||||
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
|
||||
env: [{ name: "CANVA_TOKEN", value: "secret" }],
|
||||
},
|
||||
]);
|
||||
expect(lines[1].params.mcpServers).toEqual(lines[0].params.mcpServers);
|
||||
expect(lines[2].method).toBe("session/prompt");
|
||||
expect(lines[2].params.mcpServers).toBeUndefined();
|
||||
});
|
||||
});
|
||||
105
openclaw/extensions/acpx/src/runtime.test.ts
Normal file
105
openclaw/extensions/acpx/src/runtime.test.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AcpRuntime } from "../runtime-api.js";
|
||||
import { AcpxRuntime } from "./runtime.js";
|
||||
|
||||
type TestSessionStore = {
|
||||
load(sessionId: string): Promise<Record<string, unknown> | undefined>;
|
||||
save(record: Record<string, unknown>): Promise<void>;
|
||||
};
|
||||
|
||||
function makeRuntime(baseStore: TestSessionStore): {
|
||||
runtime: AcpxRuntime;
|
||||
wrappedStore: TestSessionStore & { markFresh: (sessionKey: string) => void };
|
||||
delegate: { close: AcpRuntime["close"] };
|
||||
} {
|
||||
const runtime = new AcpxRuntime({
|
||||
cwd: "/tmp",
|
||||
sessionStore: baseStore,
|
||||
agentRegistry: {
|
||||
resolve: () => "codex",
|
||||
list: () => ["codex"],
|
||||
},
|
||||
permissionMode: "approve-reads",
|
||||
});
|
||||
|
||||
return {
|
||||
runtime,
|
||||
wrappedStore: (
|
||||
runtime as unknown as {
|
||||
sessionStore: TestSessionStore & { markFresh: (sessionKey: string) => void };
|
||||
}
|
||||
).sessionStore,
|
||||
delegate: (runtime as unknown as { delegate: { close: AcpRuntime["close"] } }).delegate,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("keeps stale persistent loads hidden until a fresh record is saved", async () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => ({ acpxRecordId: "stale" }) as never),
|
||||
save: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
const { runtime, wrappedStore } = makeRuntime(baseStore);
|
||||
|
||||
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toEqual({
|
||||
acpxRecordId: "stale",
|
||||
});
|
||||
expect(baseStore.load).toHaveBeenCalledTimes(1);
|
||||
|
||||
await runtime.prepareFreshSession({
|
||||
sessionKey: "agent:codex:acp:binding:test",
|
||||
});
|
||||
|
||||
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toBeUndefined();
|
||||
expect(baseStore.load).toHaveBeenCalledTimes(1);
|
||||
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toBeUndefined();
|
||||
expect(baseStore.load).toHaveBeenCalledTimes(1);
|
||||
|
||||
await wrappedStore.save({
|
||||
acpxRecordId: "fresh-record",
|
||||
name: "agent:codex:acp:binding:test",
|
||||
} as never);
|
||||
|
||||
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toEqual({
|
||||
acpxRecordId: "stale",
|
||||
});
|
||||
expect(baseStore.load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("marks the session fresh after discardPersistentState close", async () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => ({ acpxRecordId: "stale" }) as never),
|
||||
save: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
const { runtime, wrappedStore, delegate } = makeRuntime(baseStore);
|
||||
const close = vi.spyOn(delegate, "close").mockResolvedValue(undefined);
|
||||
|
||||
await runtime.close({
|
||||
handle: {
|
||||
sessionKey: "agent:codex:acp:binding:test",
|
||||
backend: "acpx",
|
||||
runtimeSessionName: "agent:codex:acp:binding:test",
|
||||
},
|
||||
reason: "new-in-place-reset",
|
||||
discardPersistentState: true,
|
||||
});
|
||||
|
||||
expect(close).toHaveBeenCalledWith({
|
||||
handle: {
|
||||
sessionKey: "agent:codex:acp:binding:test",
|
||||
backend: "acpx",
|
||||
runtimeSessionName: "agent:codex:acp:binding:test",
|
||||
},
|
||||
reason: "new-in-place-reset",
|
||||
discardPersistentState: true,
|
||||
});
|
||||
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toBeUndefined();
|
||||
expect(baseStore.load).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
153
openclaw/extensions/acpx/src/runtime.ts
Normal file
153
openclaw/extensions/acpx/src/runtime.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import {
|
||||
ACPX_BACKEND_ID,
|
||||
AcpxRuntime as BaseAcpxRuntime,
|
||||
createAcpRuntime,
|
||||
createAgentRegistry,
|
||||
createFileSessionStore,
|
||||
decodeAcpxRuntimeHandleState,
|
||||
encodeAcpxRuntimeHandleState,
|
||||
type AcpAgentRegistry,
|
||||
type AcpRuntimeDoctorReport,
|
||||
type AcpRuntimeEvent,
|
||||
type AcpRuntimeHandle,
|
||||
type AcpRuntimeOptions,
|
||||
type AcpRuntimeStatus,
|
||||
} from "acpx/runtime";
|
||||
import type { AcpRuntime } from "../runtime-api.js";
|
||||
|
||||
type AcpSessionStore = AcpRuntimeOptions["sessionStore"];
|
||||
type AcpSessionRecord = Parameters<AcpSessionStore["save"]>[0];
|
||||
type AcpLoadedSessionRecord = Awaited<ReturnType<AcpSessionStore["load"]>>;
|
||||
|
||||
type ResetAwareSessionStore = AcpSessionStore & {
|
||||
markFresh: (sessionKey: string) => void;
|
||||
};
|
||||
|
||||
function readSessionRecordName(record: AcpSessionRecord): string {
|
||||
if (typeof record !== "object" || record === null) {
|
||||
return "";
|
||||
}
|
||||
const { name } = record as { name?: unknown };
|
||||
return typeof name === "string" ? name.trim() : "";
|
||||
}
|
||||
|
||||
function createResetAwareSessionStore(baseStore: AcpSessionStore): ResetAwareSessionStore {
|
||||
const freshSessionKeys = new Set<string>();
|
||||
|
||||
return {
|
||||
async load(sessionId: string): Promise<AcpLoadedSessionRecord> {
|
||||
const normalized = sessionId.trim();
|
||||
if (normalized && freshSessionKeys.has(normalized)) {
|
||||
return undefined;
|
||||
}
|
||||
return await baseStore.load(sessionId);
|
||||
},
|
||||
async save(record: AcpSessionRecord): Promise<void> {
|
||||
await baseStore.save(record);
|
||||
const sessionName = readSessionRecordName(record);
|
||||
if (sessionName) {
|
||||
freshSessionKeys.delete(sessionName);
|
||||
}
|
||||
},
|
||||
markFresh(sessionKey: string): void {
|
||||
const normalized = sessionKey.trim();
|
||||
if (normalized) {
|
||||
freshSessionKeys.add(normalized);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type AcpxRuntimeLike = AcpRuntime & {
|
||||
probeAvailability(): Promise<void>;
|
||||
isHealthy(): boolean;
|
||||
doctor(): Promise<AcpRuntimeDoctorReport>;
|
||||
};
|
||||
|
||||
export class AcpxRuntime implements AcpxRuntimeLike {
|
||||
private readonly sessionStore: ResetAwareSessionStore;
|
||||
private readonly delegate: BaseAcpxRuntime;
|
||||
|
||||
constructor(
|
||||
options: AcpRuntimeOptions,
|
||||
testOptions?: ConstructorParameters<typeof BaseAcpxRuntime>[1],
|
||||
) {
|
||||
this.sessionStore = createResetAwareSessionStore(options.sessionStore);
|
||||
this.delegate = new BaseAcpxRuntime(
|
||||
{
|
||||
...options,
|
||||
sessionStore: this.sessionStore,
|
||||
},
|
||||
testOptions,
|
||||
);
|
||||
}
|
||||
|
||||
isHealthy(): boolean {
|
||||
return this.delegate.isHealthy();
|
||||
}
|
||||
|
||||
probeAvailability(): Promise<void> {
|
||||
return this.delegate.probeAvailability();
|
||||
}
|
||||
|
||||
doctor(): Promise<AcpRuntimeDoctorReport> {
|
||||
return this.delegate.doctor();
|
||||
}
|
||||
|
||||
ensureSession(input: Parameters<AcpRuntime["ensureSession"]>[0]): Promise<AcpRuntimeHandle> {
|
||||
return this.delegate.ensureSession(input);
|
||||
}
|
||||
|
||||
runTurn(input: Parameters<AcpRuntime["runTurn"]>[0]): AsyncIterable<AcpRuntimeEvent> {
|
||||
return this.delegate.runTurn(input);
|
||||
}
|
||||
|
||||
getCapabilities(): ReturnType<BaseAcpxRuntime["getCapabilities"]> {
|
||||
return this.delegate.getCapabilities();
|
||||
}
|
||||
|
||||
getStatus(input: Parameters<NonNullable<AcpRuntime["getStatus"]>>[0]): Promise<AcpRuntimeStatus> {
|
||||
return this.delegate.getStatus(input);
|
||||
}
|
||||
|
||||
setMode(input: Parameters<NonNullable<AcpRuntime["setMode"]>>[0]): Promise<void> {
|
||||
return this.delegate.setMode(input);
|
||||
}
|
||||
|
||||
setConfigOption(input: Parameters<NonNullable<AcpRuntime["setConfigOption"]>>[0]): Promise<void> {
|
||||
return this.delegate.setConfigOption(input);
|
||||
}
|
||||
|
||||
cancel(input: Parameters<AcpRuntime["cancel"]>[0]): Promise<void> {
|
||||
return this.delegate.cancel(input);
|
||||
}
|
||||
|
||||
async prepareFreshSession(input: { sessionKey: string }): Promise<void> {
|
||||
this.sessionStore.markFresh(input.sessionKey);
|
||||
}
|
||||
|
||||
close(input: Parameters<AcpRuntime["close"]>[0]): Promise<void> {
|
||||
return this.delegate
|
||||
.close({
|
||||
handle: input.handle,
|
||||
reason: input.reason,
|
||||
discardPersistentState: input.discardPersistentState,
|
||||
})
|
||||
.then(() => {
|
||||
if (input.discardPersistentState) {
|
||||
this.sessionStore.markFresh(input.handle.sessionKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
ACPX_BACKEND_ID,
|
||||
createAcpRuntime,
|
||||
createAgentRegistry,
|
||||
createFileSessionStore,
|
||||
decodeAcpxRuntimeHandleState,
|
||||
encodeAcpxRuntimeHandleState,
|
||||
};
|
||||
|
||||
export type { AcpAgentRegistry, AcpRuntimeOptions, AcpSessionRecord, AcpSessionStore };
|
||||
221
openclaw/extensions/acpx/src/service.test.ts
Normal file
221
openclaw/extensions/acpx/src/service.test.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { runtimeRegistry } = vi.hoisted(() => ({
|
||||
runtimeRegistry: new Map<string, { runtime: unknown; healthy?: () => boolean }>(),
|
||||
}));
|
||||
|
||||
vi.mock("../runtime-api.js", () => ({
|
||||
getAcpRuntimeBackend: (id: string) => runtimeRegistry.get(id),
|
||||
registerAcpRuntimeBackend: (entry: { id: string; runtime: unknown; healthy?: () => boolean }) => {
|
||||
runtimeRegistry.set(entry.id, entry);
|
||||
},
|
||||
unregisterAcpRuntimeBackend: (id: string) => {
|
||||
runtimeRegistry.delete(id);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
ACPX_BACKEND_ID: "acpx",
|
||||
AcpxRuntime: function AcpxRuntime() {},
|
||||
createAgentRegistry: vi.fn(() => ({})),
|
||||
createFileSessionStore: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
import { getAcpRuntimeBackend } from "../runtime-api.js";
|
||||
import { createAcpxRuntimeService } from "./service.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function makeTempDir(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-acpx-service-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
runtimeRegistry.clear();
|
||||
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME;
|
||||
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE;
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createServiceContext(workspaceDir: string) {
|
||||
return {
|
||||
workspaceDir,
|
||||
stateDir: path.join(workspaceDir, ".openclaw-plugin-state"),
|
||||
config: {},
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("createAcpxRuntimeService", () => {
|
||||
it("registers and unregisters the embedded backend", async () => {
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const runtime = {
|
||||
ensureSession: vi.fn(),
|
||||
runTurn: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
close: vi.fn(),
|
||||
probeAvailability: vi.fn(async () => {}),
|
||||
isHealthy: vi.fn(() => true),
|
||||
doctor: vi.fn(async () => ({ ok: true, message: "ok" })),
|
||||
};
|
||||
const service = createAcpxRuntimeService({
|
||||
runtimeFactory: () => runtime as never,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
expect(getAcpRuntimeBackend("acpx")?.runtime).toBe(runtime);
|
||||
|
||||
await service.stop?.(ctx);
|
||||
|
||||
expect(getAcpRuntimeBackend("acpx")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates the embedded runtime state directory before probing", async () => {
|
||||
const workspaceDir = await makeTempDir();
|
||||
const stateDir = path.join(workspaceDir, "custom-state");
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const probeAvailability = vi.fn(async () => {
|
||||
await fs.access(stateDir);
|
||||
});
|
||||
const service = createAcpxRuntimeService({
|
||||
pluginConfig: { stateDir },
|
||||
runtimeFactory: () =>
|
||||
({
|
||||
ensureSession: vi.fn(),
|
||||
runTurn: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
close: vi.fn(),
|
||||
probeAvailability,
|
||||
isHealthy: () => true,
|
||||
doctor: async () => ({ ok: true, message: "ok" }),
|
||||
}) as never,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
expect(probeAvailability).toHaveBeenCalledOnce();
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
it("passes the default runtime timeout to the embedded runtime factory", async () => {
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const runtime = {
|
||||
ensureSession: vi.fn(),
|
||||
runTurn: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
close: vi.fn(),
|
||||
probeAvailability: vi.fn(async () => {}),
|
||||
isHealthy: vi.fn(() => true),
|
||||
doctor: vi.fn(async () => ({ ok: true, message: "ok" })),
|
||||
};
|
||||
const runtimeFactory = vi.fn(() => runtime as never);
|
||||
const service = createAcpxRuntimeService({
|
||||
runtimeFactory,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
expect(runtimeFactory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pluginConfig: expect.objectContaining({
|
||||
timeoutSeconds: 120,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
it("warns when legacy compatibility config is explicitly ignored", async () => {
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const runtime = {
|
||||
ensureSession: vi.fn(),
|
||||
runTurn: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
close: vi.fn(),
|
||||
probeAvailability: vi.fn(async () => {}),
|
||||
isHealthy: vi.fn(() => true),
|
||||
doctor: vi.fn(async () => ({ ok: true, message: "ok" })),
|
||||
};
|
||||
const service = createAcpxRuntimeService({
|
||||
pluginConfig: {
|
||||
queueOwnerTtlSeconds: 30,
|
||||
strictWindowsCmdWrapper: false,
|
||||
},
|
||||
runtimeFactory: () => runtime as never,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
expect(ctx.logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"embedded acpx runtime ignores legacy compatibility config: queueOwnerTtlSeconds, strictWindowsCmdWrapper=false",
|
||||
),
|
||||
);
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
it("can skip the embedded runtime probe via env", async () => {
|
||||
process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE = "1";
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const probeAvailability = vi.fn(async () => {});
|
||||
const service = createAcpxRuntimeService({
|
||||
runtimeFactory: () =>
|
||||
({
|
||||
ensureSession: vi.fn(),
|
||||
runTurn: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
close: vi.fn(),
|
||||
probeAvailability,
|
||||
isHealthy: () => false,
|
||||
doctor: async () => ({ ok: false, message: "nope" }),
|
||||
}) as never,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
expect(probeAvailability).not.toHaveBeenCalled();
|
||||
expect(getAcpRuntimeBackend("acpx")).toBeTruthy();
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
it("can skip the embedded runtime backend via env", async () => {
|
||||
process.env.OPENCLAW_SKIP_ACPX_RUNTIME = "1";
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const runtimeFactory = vi.fn(() => {
|
||||
throw new Error("runtime factory should not run when ACPX is skipped");
|
||||
});
|
||||
const service = createAcpxRuntimeService({
|
||||
runtimeFactory: runtimeFactory as never,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
expect(runtimeFactory).not.toHaveBeenCalled();
|
||||
expect(getAcpRuntimeBackend("acpx")).toBeUndefined();
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
"skipping embedded acpx runtime backend (OPENCLAW_SKIP_ACPX_RUNTIME=1)",
|
||||
);
|
||||
});
|
||||
});
|
||||
159
openclaw/extensions/acpx/src/service.ts
Normal file
159
openclaw/extensions/acpx/src/service.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import fs from "node:fs/promises";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import type {
|
||||
AcpRuntime,
|
||||
OpenClawPluginService,
|
||||
OpenClawPluginServiceContext,
|
||||
PluginLogger,
|
||||
} from "../runtime-api.js";
|
||||
import { registerAcpRuntimeBackend, unregisterAcpRuntimeBackend } from "../runtime-api.js";
|
||||
import {
|
||||
resolveAcpxPluginConfig,
|
||||
toAcpMcpServers,
|
||||
type ResolvedAcpxPluginConfig,
|
||||
} from "./config.js";
|
||||
import {
|
||||
ACPX_BACKEND_ID,
|
||||
AcpxRuntime,
|
||||
createAgentRegistry,
|
||||
createFileSessionStore,
|
||||
} from "./runtime.js";
|
||||
|
||||
type AcpxRuntimeLike = AcpRuntime & {
|
||||
probeAvailability(): Promise<void>;
|
||||
isHealthy(): boolean;
|
||||
doctor?(): Promise<{
|
||||
ok: boolean;
|
||||
message: string;
|
||||
details?: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
type AcpxRuntimeFactoryParams = {
|
||||
pluginConfig: ResolvedAcpxPluginConfig;
|
||||
logger?: PluginLogger;
|
||||
};
|
||||
|
||||
type CreateAcpxRuntimeServiceParams = {
|
||||
pluginConfig?: unknown;
|
||||
runtimeFactory?: (params: AcpxRuntimeFactoryParams) => AcpxRuntimeLike;
|
||||
};
|
||||
|
||||
function createDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntimeLike {
|
||||
return new AcpxRuntime({
|
||||
cwd: params.pluginConfig.cwd,
|
||||
sessionStore: createFileSessionStore({
|
||||
stateDir: params.pluginConfig.stateDir,
|
||||
}),
|
||||
agentRegistry: createAgentRegistry({
|
||||
overrides: params.pluginConfig.agents,
|
||||
}),
|
||||
mcpServers: toAcpMcpServers(params.pluginConfig.mcpServers),
|
||||
permissionMode: params.pluginConfig.permissionMode,
|
||||
nonInteractivePermissions: params.pluginConfig.nonInteractivePermissions,
|
||||
timeoutMs:
|
||||
params.pluginConfig.timeoutSeconds != null
|
||||
? params.pluginConfig.timeoutSeconds * 1_000
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function warnOnIgnoredLegacyCompatibilityConfig(params: {
|
||||
pluginConfig: ResolvedAcpxPluginConfig;
|
||||
logger?: PluginLogger;
|
||||
}): void {
|
||||
const ignoredFields: string[] = [];
|
||||
if (params.pluginConfig.legacyCompatibilityConfig.queueOwnerTtlSeconds != null) {
|
||||
ignoredFields.push("queueOwnerTtlSeconds");
|
||||
}
|
||||
if (params.pluginConfig.legacyCompatibilityConfig.strictWindowsCmdWrapper === false) {
|
||||
ignoredFields.push("strictWindowsCmdWrapper=false");
|
||||
}
|
||||
if (ignoredFields.length === 0) {
|
||||
return;
|
||||
}
|
||||
params.logger?.warn(
|
||||
`embedded acpx runtime ignores legacy compatibility config: ${ignoredFields.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function formatDoctorFailureMessage(report: { message: string; details?: string[] }): string {
|
||||
const detailText = report.details?.filter(Boolean).join("; ").trim();
|
||||
return detailText ? `${report.message} (${detailText})` : report.message;
|
||||
}
|
||||
|
||||
export function createAcpxRuntimeService(
|
||||
params: CreateAcpxRuntimeServiceParams = {},
|
||||
): OpenClawPluginService {
|
||||
let runtime: AcpxRuntimeLike | null = null;
|
||||
let lifecycleRevision = 0;
|
||||
|
||||
return {
|
||||
id: "acpx-runtime",
|
||||
async start(ctx: OpenClawPluginServiceContext): Promise<void> {
|
||||
if (process.env.OPENCLAW_SKIP_ACPX_RUNTIME === "1") {
|
||||
ctx.logger.info("skipping embedded acpx runtime backend (OPENCLAW_SKIP_ACPX_RUNTIME=1)");
|
||||
return;
|
||||
}
|
||||
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: params.pluginConfig,
|
||||
workspaceDir: ctx.workspaceDir,
|
||||
});
|
||||
await fs.mkdir(pluginConfig.stateDir, { recursive: true });
|
||||
warnOnIgnoredLegacyCompatibilityConfig({
|
||||
pluginConfig,
|
||||
logger: ctx.logger,
|
||||
});
|
||||
|
||||
const runtimeFactory = params.runtimeFactory ?? createDefaultRuntime;
|
||||
runtime = runtimeFactory({
|
||||
pluginConfig,
|
||||
logger: ctx.logger,
|
||||
});
|
||||
|
||||
registerAcpRuntimeBackend({
|
||||
id: ACPX_BACKEND_ID,
|
||||
runtime,
|
||||
healthy: () => runtime?.isHealthy() ?? false,
|
||||
});
|
||||
ctx.logger.info(`embedded acpx runtime backend registered (cwd: ${pluginConfig.cwd})`);
|
||||
|
||||
if (process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE === "1") {
|
||||
return;
|
||||
}
|
||||
|
||||
lifecycleRevision += 1;
|
||||
const currentRevision = lifecycleRevision;
|
||||
void (async () => {
|
||||
try {
|
||||
await runtime?.probeAvailability();
|
||||
if (currentRevision !== lifecycleRevision) {
|
||||
return;
|
||||
}
|
||||
if (runtime?.isHealthy()) {
|
||||
ctx.logger.info("embedded acpx runtime backend ready");
|
||||
return;
|
||||
}
|
||||
const doctorReport = await runtime?.doctor?.();
|
||||
if (currentRevision !== lifecycleRevision) {
|
||||
return;
|
||||
}
|
||||
ctx.logger.warn(
|
||||
`embedded acpx runtime backend probe failed: ${doctorReport ? formatDoctorFailureMessage(doctorReport) : "backend remained unhealthy after probe"}`,
|
||||
);
|
||||
} catch (err) {
|
||||
if (currentRevision !== lifecycleRevision) {
|
||||
return;
|
||||
}
|
||||
ctx.logger.warn(`embedded acpx runtime setup failed: ${formatErrorMessage(err)}`);
|
||||
}
|
||||
})();
|
||||
},
|
||||
async stop(_ctx: OpenClawPluginServiceContext): Promise<void> {
|
||||
lifecycleRevision += 1;
|
||||
unregisterAcpRuntimeBackend(ACPX_BACKEND_ID);
|
||||
runtime = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
16
openclaw/extensions/acpx/tsconfig.json
Normal file
16
openclaw/extensions/acpx/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue