mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 11:18:37 +00:00
fix: land #39337 by @goodspeed-apps for acpx MCP bootstrap
Co-authored-by: Goodspeed App Studio <goodspeed-apps@users.noreply.github.com>
This commit is contained in:
@@ -349,6 +349,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Control UI/agents-page overrides: auto-create minimal per-agent config entries when editing inherited agents, so model/tool/skill changes enable Save and inherited model fallbacks can be cleared by writing a primary-only override. Landed from contributor PR #39326 by @dunamismax. Thanks @dunamismax.
|
- Control UI/agents-page overrides: auto-create minimal per-agent config entries when editing inherited agents, so model/tool/skill changes enable Save and inherited model fallbacks can be cleared by writing a primary-only override. Landed from contributor PR #39326 by @dunamismax. Thanks @dunamismax.
|
||||||
- Gateway/Telegram webhook-mode recovery: add `webhookCertPath` to re-upload self-signed certificates during webhook registration and skip stale-socket detection for webhook-mode channels, so Telegram webhook setups survive health-monitor restarts. Landed from contributor PR #39313 by @fellanH. Thanks @fellanH.
|
- Gateway/Telegram webhook-mode recovery: add `webhookCertPath` to re-upload self-signed certificates during webhook registration and skip stale-socket detection for webhook-mode channels, so Telegram webhook setups survive health-monitor restarts. Landed from contributor PR #39313 by @fellanH. Thanks @fellanH.
|
||||||
- Discord/config schema parity: add `channels.discord.agentComponents` to the strict Zod config schema so valid `agentComponents.enabled` settings (root and account-scoped) no longer fail with unrecognized-key validation errors. Landed from contributor PR #39378 by @gambletan. Thanks @gambletan and @thewilloftheshadow.
|
- Discord/config schema parity: add `channels.discord.agentComponents` to the strict Zod config schema so valid `agentComponents.enabled` settings (root and account-scoped) no longer fail with unrecognized-key validation errors. Landed from contributor PR #39378 by @gambletan. Thanks @gambletan and @thewilloftheshadow.
|
||||||
|
- ACPX/MCP session bootstrap: inject configured MCP servers into ACP `session/new` and `session/load` for acpx-backed sessions, restoring Canva and other external MCP tools. Landed from contributor PR #39337 by @goodspeed-apps. Thanks @goodspeed-apps.
|
||||||
|
|
||||||
## 2026.3.2
|
## 2026.3.2
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,29 @@
|
|||||||
"queueOwnerTtlSeconds": {
|
"queueOwnerTtlSeconds": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"minimum": 0
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"mcpServers": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"command": {
|
||||||
|
"type": "string",
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -72,6 +95,11 @@
|
|||||||
"label": "Queue Owner TTL Seconds",
|
"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.",
|
"help": "Idle queue-owner TTL for acpx prompt turns. Keep this short in OpenClaw to avoid delayed completion after each turn.",
|
||||||
"advanced": true
|
"advanced": true
|
||||||
|
},
|
||||||
|
"mcpServers": {
|
||||||
|
"label": "MCP Servers",
|
||||||
|
"help": "Named MCP server definitions to inject into ACPX-backed session bootstrap. Each entry needs a command and can include args and env.",
|
||||||
|
"advanced": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ACPX_PINNED_VERSION,
|
ACPX_PINNED_VERSION,
|
||||||
createAcpxPluginConfigSchema,
|
createAcpxPluginConfigSchema,
|
||||||
resolveAcpxPluginConfig,
|
resolveAcpxPluginConfig,
|
||||||
|
toAcpMcpServers,
|
||||||
} from "./config.js";
|
} from "./config.js";
|
||||||
|
|
||||||
describe("acpx plugin config parsing", () => {
|
describe("acpx plugin config parsing", () => {
|
||||||
@@ -21,6 +22,7 @@ describe("acpx plugin config parsing", () => {
|
|||||||
expect(resolved.allowPluginLocalInstall).toBe(true);
|
expect(resolved.allowPluginLocalInstall).toBe(true);
|
||||||
expect(resolved.cwd).toBe(path.resolve("/tmp/workspace"));
|
expect(resolved.cwd).toBe(path.resolve("/tmp/workspace"));
|
||||||
expect(resolved.strictWindowsCmdWrapper).toBe(true);
|
expect(resolved.strictWindowsCmdWrapper).toBe(true);
|
||||||
|
expect(resolved.mcpServers).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts command override and disables plugin-local auto-install", () => {
|
it("accepts command override and disables plugin-local auto-install", () => {
|
||||||
@@ -132,4 +134,97 @@ describe("acpx plugin config parsing", () => {
|
|||||||
}),
|
}),
|
||||||
).toThrow("strictWindowsCmdWrapper must be a boolean");
|
).toThrow("strictWindowsCmdWrapper must be a boolean");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts mcp server maps", () => {
|
||||||
|
const resolved = resolveAcpxPluginConfig({
|
||||||
|
rawConfig: {
|
||||||
|
mcpServers: {
|
||||||
|
canva: {
|
||||||
|
command: "npx",
|
||||||
|
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
|
||||||
|
env: {
|
||||||
|
CANVA_TOKEN: "secret",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
workspaceDir: "/tmp/workspace",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolved.mcpServers).toEqual({
|
||||||
|
canva: {
|
||||||
|
command: "npx",
|
||||||
|
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
|
||||||
|
env: {
|
||||||
|
CANVA_TOKEN: "secret",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid mcp server definitions", () => {
|
||||||
|
expect(() =>
|
||||||
|
resolveAcpxPluginConfig({
|
||||||
|
rawConfig: {
|
||||||
|
mcpServers: {
|
||||||
|
canva: {
|
||||||
|
command: "npx",
|
||||||
|
args: ["-y", 1],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
workspaceDir: "/tmp/workspace",
|
||||||
|
}),
|
||||||
|
).toThrow(
|
||||||
|
"mcpServers.canva must have a command string, optional args array, and optional env object",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schema accepts mcp server config", () => {
|
||||||
|
const schema = createAcpxPluginConfigSchema();
|
||||||
|
if (!schema.safeParse) {
|
||||||
|
throw new Error("acpx config schema missing safeParse");
|
||||||
|
}
|
||||||
|
const parsed = schema.safeParse({
|
||||||
|
mcpServers: {
|
||||||
|
canva: {
|
||||||
|
command: "npx",
|
||||||
|
args: ["-y", "mcp-remote@latest"],
|
||||||
|
env: {
|
||||||
|
CANVA_TOKEN: "secret",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toAcpMcpServers", () => {
|
||||||
|
it("converts plugin config maps into ACP stdio MCP entries", () => {
|
||||||
|
expect(
|
||||||
|
toAcpMcpServers({
|
||||||
|
canva: {
|
||||||
|
command: "npx",
|
||||||
|
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
|
||||||
|
env: {
|
||||||
|
CANVA_TOKEN: "secret",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
name: "canva",
|
||||||
|
command: "npx",
|
||||||
|
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
|
||||||
|
env: [
|
||||||
|
{
|
||||||
|
name: "CANVA_TOKEN",
|
||||||
|
value: "secret",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,19 @@ export function buildAcpxLocalInstallCommand(version: string = ACPX_PINNED_VERSI
|
|||||||
}
|
}
|
||||||
export const ACPX_LOCAL_INSTALL_COMMAND = buildAcpxLocalInstallCommand();
|
export const ACPX_LOCAL_INSTALL_COMMAND = buildAcpxLocalInstallCommand();
|
||||||
|
|
||||||
|
export type McpServerConfig = {
|
||||||
|
command: string;
|
||||||
|
args?: string[];
|
||||||
|
env?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AcpxMcpServer = {
|
||||||
|
name: string;
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
env: Array<{ name: string; value: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
export type AcpxPluginConfig = {
|
export type AcpxPluginConfig = {
|
||||||
command?: string;
|
command?: string;
|
||||||
expectedVersion?: string;
|
expectedVersion?: string;
|
||||||
@@ -27,6 +40,7 @@ export type AcpxPluginConfig = {
|
|||||||
strictWindowsCmdWrapper?: boolean;
|
strictWindowsCmdWrapper?: boolean;
|
||||||
timeoutSeconds?: number;
|
timeoutSeconds?: number;
|
||||||
queueOwnerTtlSeconds?: number;
|
queueOwnerTtlSeconds?: number;
|
||||||
|
mcpServers?: Record<string, McpServerConfig>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ResolvedAcpxPluginConfig = {
|
export type ResolvedAcpxPluginConfig = {
|
||||||
@@ -40,6 +54,7 @@ export type ResolvedAcpxPluginConfig = {
|
|||||||
strictWindowsCmdWrapper: boolean;
|
strictWindowsCmdWrapper: boolean;
|
||||||
timeoutSeconds?: number;
|
timeoutSeconds?: number;
|
||||||
queueOwnerTtlSeconds: number;
|
queueOwnerTtlSeconds: number;
|
||||||
|
mcpServers: Record<string, McpServerConfig>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_PERMISSION_MODE: AcpxPermissionMode = "approve-reads";
|
const DEFAULT_PERMISSION_MODE: AcpxPermissionMode = "approve-reads";
|
||||||
@@ -65,6 +80,36 @@ function isNonInteractivePermissionPolicy(
|
|||||||
return ACPX_NON_INTERACTIVE_POLICIES.includes(value as AcpxNonInteractivePermissionPolicy);
|
return ACPX_NON_INTERACTIVE_POLICIES.includes(value as AcpxNonInteractivePermissionPolicy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMcpServerConfig(value: unknown): value is McpServerConfig {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (typeof value.command !== "string" || value.command.trim() === "") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (value.args !== undefined) {
|
||||||
|
if (!Array.isArray(value.args)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const arg of value.args) {
|
||||||
|
if (typeof arg !== "string") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value.env !== undefined) {
|
||||||
|
if (!isRecord(value.env)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const envValue of Object.values(value.env)) {
|
||||||
|
if (typeof envValue !== "string") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function parseAcpxPluginConfig(value: unknown): ParseResult {
|
function parseAcpxPluginConfig(value: unknown): ParseResult {
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
return { ok: true, value: undefined };
|
return { ok: true, value: undefined };
|
||||||
@@ -81,6 +126,7 @@ function parseAcpxPluginConfig(value: unknown): ParseResult {
|
|||||||
"strictWindowsCmdWrapper",
|
"strictWindowsCmdWrapper",
|
||||||
"timeoutSeconds",
|
"timeoutSeconds",
|
||||||
"queueOwnerTtlSeconds",
|
"queueOwnerTtlSeconds",
|
||||||
|
"mcpServers",
|
||||||
]);
|
]);
|
||||||
for (const key of Object.keys(value)) {
|
for (const key of Object.keys(value)) {
|
||||||
if (!allowedKeys.has(key)) {
|
if (!allowedKeys.has(key)) {
|
||||||
@@ -152,6 +198,21 @@ function parseAcpxPluginConfig(value: unknown): ParseResult {
|
|||||||
return { ok: false, message: "queueOwnerTtlSeconds must be a non-negative number" };
|
return { ok: false, message: "queueOwnerTtlSeconds must be a non-negative number" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mcpServers = value.mcpServers;
|
||||||
|
if (mcpServers !== undefined) {
|
||||||
|
if (!isRecord(mcpServers)) {
|
||||||
|
return { ok: false, message: "mcpServers must be an object" };
|
||||||
|
}
|
||||||
|
for (const [key, serverConfig] of Object.entries(mcpServers)) {
|
||||||
|
if (!isMcpServerConfig(serverConfig)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: `mcpServers.${key} must have a command string, optional args array, and optional env object`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
value: {
|
value: {
|
||||||
@@ -166,6 +227,7 @@ function parseAcpxPluginConfig(value: unknown): ParseResult {
|
|||||||
timeoutSeconds: typeof timeoutSeconds === "number" ? timeoutSeconds : undefined,
|
timeoutSeconds: typeof timeoutSeconds === "number" ? timeoutSeconds : undefined,
|
||||||
queueOwnerTtlSeconds:
|
queueOwnerTtlSeconds:
|
||||||
typeof queueOwnerTtlSeconds === "number" ? queueOwnerTtlSeconds : undefined,
|
typeof queueOwnerTtlSeconds === "number" ? queueOwnerTtlSeconds : undefined,
|
||||||
|
mcpServers: mcpServers as Record<string, McpServerConfig> | undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -219,11 +281,41 @@ export function createAcpxPluginConfigSchema(): OpenClawPluginConfigSchema {
|
|||||||
strictWindowsCmdWrapper: { type: "boolean" },
|
strictWindowsCmdWrapper: { type: "boolean" },
|
||||||
timeoutSeconds: { type: "number", minimum: 0.001 },
|
timeoutSeconds: { type: "number", minimum: 0.001 },
|
||||||
queueOwnerTtlSeconds: { type: "number", minimum: 0 },
|
queueOwnerTtlSeconds: { type: "number", minimum: 0 },
|
||||||
|
mcpServers: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
command: { type: "string" },
|
||||||
|
args: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
},
|
||||||
|
env: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["command"],
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toAcpMcpServers(mcpServers: Record<string, McpServerConfig>): AcpxMcpServer[] {
|
||||||
|
return Object.entries(mcpServers).map(([name, server]) => ({
|
||||||
|
name,
|
||||||
|
command: server.command,
|
||||||
|
args: [...(server.args ?? [])],
|
||||||
|
env: Object.entries(server.env ?? {}).map(([envName, value]) => ({
|
||||||
|
name: envName,
|
||||||
|
value,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveAcpxPluginConfig(params: {
|
export function resolveAcpxPluginConfig(params: {
|
||||||
rawConfig: unknown;
|
rawConfig: unknown;
|
||||||
workspaceDir?: string;
|
workspaceDir?: string;
|
||||||
@@ -260,5 +352,6 @@ export function resolveAcpxPluginConfig(params: {
|
|||||||
normalized.strictWindowsCmdWrapper ?? DEFAULT_STRICT_WINDOWS_CMD_WRAPPER,
|
normalized.strictWindowsCmdWrapper ?? DEFAULT_STRICT_WINDOWS_CMD_WRAPPER,
|
||||||
timeoutSeconds: normalized.timeoutSeconds,
|
timeoutSeconds: normalized.timeoutSeconds,
|
||||||
queueOwnerTtlSeconds: normalized.queueOwnerTtlSeconds ?? DEFAULT_QUEUE_OWNER_TTL_SECONDS,
|
queueOwnerTtlSeconds: normalized.queueOwnerTtlSeconds ?? DEFAULT_QUEUE_OWNER_TTL_SECONDS,
|
||||||
|
mcpServers: normalized.mcpServers ?? {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
113
extensions/acpx/src/runtime-internals/mcp-agent-command.ts
Normal file
113
extensions/acpx/src/runtime-internals/mcp-agent-command.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { spawnAndCollect, type SpawnCommandOptions } from "./process.js";
|
||||||
|
|
||||||
|
const ACPX_BUILTIN_AGENT_COMMANDS: Record<string, string> = {
|
||||||
|
codex: "npx @zed-industries/codex-acp",
|
||||||
|
claude: "npx -y @zed-industries/claude-agent-acp",
|
||||||
|
gemini: "gemini",
|
||||||
|
opencode: "npx -y opencode-ai acp",
|
||||||
|
pi: "npx pi-acp",
|
||||||
|
};
|
||||||
|
|
||||||
|
const MCP_PROXY_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "mcp-proxy.mjs");
|
||||||
|
|
||||||
|
type AcpxConfigDisplay = {
|
||||||
|
agents?: Record<string, { command?: unknown }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AcpMcpServer = {
|
||||||
|
name: string;
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
env: Array<{ name: string; value: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeAgentName(value: string): string {
|
||||||
|
return value.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoteCommandPart(value: string): string {
|
||||||
|
if (value === "") {
|
||||||
|
return '""';
|
||||||
|
}
|
||||||
|
if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return `"${value.replace(/["\\]/g, "\\$&")}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCommandLine(parts: string[]): string {
|
||||||
|
return parts.map(quoteCommandPart).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readConfiguredAgentOverrides(value: unknown): Record<string, string> {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const overrides: Record<string, string> = {};
|
||||||
|
for (const [name, entry] of Object.entries(value)) {
|
||||||
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const command = (entry as { command?: unknown }).command;
|
||||||
|
if (typeof command !== "string" || command.trim() === "") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
overrides[normalizeAgentName(name)] = command.trim();
|
||||||
|
}
|
||||||
|
return overrides;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAgentOverrides(params: {
|
||||||
|
acpxCommand: string;
|
||||||
|
cwd: string;
|
||||||
|
spawnOptions?: SpawnCommandOptions;
|
||||||
|
}): Promise<Record<string, string>> {
|
||||||
|
const result = await spawnAndCollect(
|
||||||
|
{
|
||||||
|
command: params.acpxCommand,
|
||||||
|
args: ["--cwd", params.cwd, "config", "show"],
|
||||||
|
cwd: params.cwd,
|
||||||
|
},
|
||||||
|
params.spawnOptions,
|
||||||
|
);
|
||||||
|
if (result.error || (result.code ?? 0) !== 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(result.stdout) as AcpxConfigDisplay;
|
||||||
|
return readConfiguredAgentOverrides(parsed.agents);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveAcpxAgentCommand(params: {
|
||||||
|
acpxCommand: string;
|
||||||
|
cwd: string;
|
||||||
|
agent: string;
|
||||||
|
spawnOptions?: SpawnCommandOptions;
|
||||||
|
}): Promise<string> {
|
||||||
|
const normalizedAgent = normalizeAgentName(params.agent);
|
||||||
|
const overrides = await loadAgentOverrides({
|
||||||
|
acpxCommand: params.acpxCommand,
|
||||||
|
cwd: params.cwd,
|
||||||
|
spawnOptions: params.spawnOptions,
|
||||||
|
});
|
||||||
|
return overrides[normalizedAgent] ?? ACPX_BUILTIN_AGENT_COMMANDS[normalizedAgent] ?? params.agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMcpProxyAgentCommand(params: {
|
||||||
|
targetCommand: string;
|
||||||
|
mcpServers: AcpMcpServer[];
|
||||||
|
}): string {
|
||||||
|
const payload = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
targetCommand: params.targetCommand,
|
||||||
|
mcpServers: params.mcpServers,
|
||||||
|
}),
|
||||||
|
"utf8",
|
||||||
|
).toString("base64url");
|
||||||
|
return toCommandLine([process.execPath, MCP_PROXY_PATH, "--payload", payload]);
|
||||||
|
}
|
||||||
151
extensions/acpx/src/runtime-internals/mcp-proxy.mjs
Normal file
151
extensions/acpx/src/runtime-internals/mcp-proxy.mjs
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { createInterface } from "node:readline";
|
||||||
|
|
||||||
|
function splitCommandLine(value) {
|
||||||
|
const parts = [];
|
||||||
|
let current = "";
|
||||||
|
let quote = null;
|
||||||
|
let escaping = false;
|
||||||
|
|
||||||
|
for (const ch of value) {
|
||||||
|
if (escaping) {
|
||||||
|
current += ch;
|
||||||
|
escaping = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "\\" && quote !== "'") {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
if (parts.length === 0) {
|
||||||
|
throw new Error("Invalid agent command: empty command");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
command: parts[0],
|
||||||
|
args: parts.slice(1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("close", (code, signal) => {
|
||||||
|
if (signal) {
|
||||||
|
process.kill(process.pid, signal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
process.exit(code ?? 0);
|
||||||
|
});
|
||||||
114
extensions/acpx/src/runtime-internals/mcp-proxy.test.ts
Normal file
114
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";
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
const proxyPath = path.resolve("extensions/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"));
|
||||||
|
rl.on("close", () => process.exit(0));
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -52,7 +52,8 @@ const commandIndex = args.findIndex(
|
|||||||
arg === "sessions" ||
|
arg === "sessions" ||
|
||||||
arg === "set-mode" ||
|
arg === "set-mode" ||
|
||||||
arg === "set" ||
|
arg === "set" ||
|
||||||
arg === "status",
|
arg === "status" ||
|
||||||
|
arg === "config",
|
||||||
);
|
);
|
||||||
const command = commandIndex >= 0 ? args[commandIndex] : "";
|
const command = commandIndex >= 0 ? args[commandIndex] : "";
|
||||||
const agent = commandIndex > 0 ? args[commandIndex - 1] : "unknown";
|
const agent = commandIndex > 0 ? args[commandIndex - 1] : "unknown";
|
||||||
@@ -107,6 +108,32 @@ if (command === "sessions" && args[commandIndex + 1] === "new") {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (command === "config" && args[commandIndex + 1] === "show") {
|
||||||
|
const configuredAgents = process.env.MOCK_ACPX_CONFIG_SHOW_AGENTS
|
||||||
|
? JSON.parse(process.env.MOCK_ACPX_CONFIG_SHOW_AGENTS)
|
||||||
|
: {};
|
||||||
|
emitJson({
|
||||||
|
defaultAgent: "codex",
|
||||||
|
defaultPermissions: "approve-reads",
|
||||||
|
nonInteractivePermissions: "deny",
|
||||||
|
authPolicy: "skip",
|
||||||
|
ttl: 300,
|
||||||
|
timeout: null,
|
||||||
|
format: "text",
|
||||||
|
agents: configuredAgents,
|
||||||
|
authMethods: [],
|
||||||
|
paths: {
|
||||||
|
global: "/tmp/mock-global.json",
|
||||||
|
project: "/tmp/mock-project.json",
|
||||||
|
},
|
||||||
|
loaded: {
|
||||||
|
global: false,
|
||||||
|
project: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
if (command === "cancel") {
|
if (command === "cancel") {
|
||||||
writeLog({ kind: "cancel", agent, args, sessionName: sessionFromOption });
|
writeLog({ kind: "cancel", agent, args, sessionName: sessionFromOption });
|
||||||
emitJson({
|
emitJson({
|
||||||
@@ -285,6 +312,7 @@ process.exit(2);
|
|||||||
export async function createMockRuntimeFixture(params?: {
|
export async function createMockRuntimeFixture(params?: {
|
||||||
permissionMode?: ResolvedAcpxPluginConfig["permissionMode"];
|
permissionMode?: ResolvedAcpxPluginConfig["permissionMode"];
|
||||||
queueOwnerTtlSeconds?: number;
|
queueOwnerTtlSeconds?: number;
|
||||||
|
mcpServers?: ResolvedAcpxPluginConfig["mcpServers"];
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
runtime: AcpxRuntime;
|
runtime: AcpxRuntime;
|
||||||
logPath: string;
|
logPath: string;
|
||||||
@@ -304,6 +332,7 @@ export async function createMockRuntimeFixture(params?: {
|
|||||||
nonInteractivePermissions: "fail",
|
nonInteractivePermissions: "fail",
|
||||||
strictWindowsCmdWrapper: true,
|
strictWindowsCmdWrapper: true,
|
||||||
queueOwnerTtlSeconds: params?.queueOwnerTtlSeconds ?? 0.1,
|
queueOwnerTtlSeconds: params?.queueOwnerTtlSeconds ?? 0.1,
|
||||||
|
mcpServers: params?.mcpServers ?? {},
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -322,6 +322,58 @@ describe("AcpxRuntime", () => {
|
|||||||
expect(logs.find((entry) => entry.kind === "status")).toBeDefined();
|
expect(logs.find((entry) => entry.kind === "status")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("routes ACPX commands through an MCP proxy agent when MCP servers are configured", async () => {
|
||||||
|
process.env.MOCK_ACPX_CONFIG_SHOW_AGENTS = JSON.stringify({
|
||||||
|
codex: {
|
||||||
|
command: "npx custom-codex-acp",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const { runtime, logPath } = await createMockRuntimeFixture({
|
||||||
|
mcpServers: {
|
||||||
|
canva: {
|
||||||
|
command: "npx",
|
||||||
|
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
|
||||||
|
env: {
|
||||||
|
CANVA_TOKEN: "secret",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handle = await runtime.ensureSession({
|
||||||
|
sessionKey: "agent:codex:acp:mcp",
|
||||||
|
agent: "codex",
|
||||||
|
mode: "persistent",
|
||||||
|
});
|
||||||
|
await runtime.setMode({
|
||||||
|
handle,
|
||||||
|
mode: "plan",
|
||||||
|
});
|
||||||
|
|
||||||
|
const logs = await readMockRuntimeLogEntries(logPath);
|
||||||
|
const ensureArgs = (logs.find((entry) => entry.kind === "ensure")?.args as string[]) ?? [];
|
||||||
|
const setModeArgs = (logs.find((entry) => entry.kind === "set-mode")?.args as string[]) ?? [];
|
||||||
|
|
||||||
|
for (const args of [ensureArgs, setModeArgs]) {
|
||||||
|
const agentFlagIndex = args.indexOf("--agent");
|
||||||
|
expect(agentFlagIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
const rawAgentCommand = args[agentFlagIndex + 1];
|
||||||
|
expect(rawAgentCommand).toContain("mcp-proxy.mjs");
|
||||||
|
const payloadMatch = rawAgentCommand.match(/--payload\s+([A-Za-z0-9_-]+)/);
|
||||||
|
expect(payloadMatch?.[1]).toBeDefined();
|
||||||
|
const payload = JSON.parse(
|
||||||
|
Buffer.from(String(payloadMatch?.[1]), "base64url").toString("utf8"),
|
||||||
|
) as {
|
||||||
|
targetCommand: string;
|
||||||
|
};
|
||||||
|
expect(payload.targetCommand).toContain("custom-codex-acp");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
delete process.env.MOCK_ACPX_CONFIG_SHOW_AGENTS;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("skips prompt execution when runTurn starts with an already-aborted signal", async () => {
|
it("skips prompt execution when runTurn starts with an already-aborted signal", async () => {
|
||||||
const { runtime, logPath } = await createMockRuntimeFixture();
|
const { runtime, logPath } = await createMockRuntimeFixture();
|
||||||
const handle = await runtime.ensureSession({
|
const handle = await runtime.ensureSession({
|
||||||
|
|||||||
@@ -12,13 +12,17 @@ import type {
|
|||||||
PluginLogger,
|
PluginLogger,
|
||||||
} from "openclaw/plugin-sdk/acpx";
|
} from "openclaw/plugin-sdk/acpx";
|
||||||
import { AcpRuntimeError } from "openclaw/plugin-sdk/acpx";
|
import { AcpRuntimeError } from "openclaw/plugin-sdk/acpx";
|
||||||
import { type ResolvedAcpxPluginConfig } from "./config.js";
|
import { toAcpMcpServers, type ResolvedAcpxPluginConfig } from "./config.js";
|
||||||
import { checkAcpxVersion } from "./ensure.js";
|
import { checkAcpxVersion } from "./ensure.js";
|
||||||
import {
|
import {
|
||||||
parseJsonLines,
|
parseJsonLines,
|
||||||
parsePromptEventLine,
|
parsePromptEventLine,
|
||||||
toAcpxErrorEvent,
|
toAcpxErrorEvent,
|
||||||
} from "./runtime-internals/events.js";
|
} from "./runtime-internals/events.js";
|
||||||
|
import {
|
||||||
|
buildMcpProxyAgentCommand,
|
||||||
|
resolveAcpxAgentCommand,
|
||||||
|
} from "./runtime-internals/mcp-agent-command.js";
|
||||||
import {
|
import {
|
||||||
resolveSpawnFailure,
|
resolveSpawnFailure,
|
||||||
type SpawnCommandCache,
|
type SpawnCommandCache,
|
||||||
@@ -118,6 +122,7 @@ export class AcpxRuntime implements AcpRuntime {
|
|||||||
private readonly logger?: PluginLogger;
|
private readonly logger?: PluginLogger;
|
||||||
private readonly queueOwnerTtlSeconds: number;
|
private readonly queueOwnerTtlSeconds: number;
|
||||||
private readonly spawnCommandCache: SpawnCommandCache = {};
|
private readonly spawnCommandCache: SpawnCommandCache = {};
|
||||||
|
private readonly mcpProxyAgentCommandCache = new Map<string, string>();
|
||||||
private readonly spawnCommandOptions: SpawnCommandOptions;
|
private readonly spawnCommandOptions: SpawnCommandOptions;
|
||||||
private readonly loggedSpawnResolutions = new Set<string>();
|
private readonly loggedSpawnResolutions = new Set<string>();
|
||||||
|
|
||||||
@@ -198,12 +203,14 @@ export class AcpxRuntime implements AcpRuntime {
|
|||||||
}
|
}
|
||||||
const cwd = asTrimmedString(input.cwd) || this.config.cwd;
|
const cwd = asTrimmedString(input.cwd) || this.config.cwd;
|
||||||
const mode = input.mode;
|
const mode = input.mode;
|
||||||
|
const ensureCommand = await this.buildVerbArgs({
|
||||||
|
agent,
|
||||||
|
cwd,
|
||||||
|
command: ["sessions", "ensure", "--name", sessionName],
|
||||||
|
});
|
||||||
|
|
||||||
let events = await this.runControlCommand({
|
let events = await this.runControlCommand({
|
||||||
args: this.buildControlArgs({
|
args: ensureCommand,
|
||||||
cwd,
|
|
||||||
command: [agent, "sessions", "ensure", "--name", sessionName],
|
|
||||||
}),
|
|
||||||
cwd,
|
cwd,
|
||||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||||
});
|
});
|
||||||
@@ -215,11 +222,13 @@ export class AcpxRuntime implements AcpRuntime {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!ensuredEvent) {
|
if (!ensuredEvent) {
|
||||||
|
const newCommand = await this.buildVerbArgs({
|
||||||
|
agent,
|
||||||
|
cwd,
|
||||||
|
command: ["sessions", "new", "--name", sessionName],
|
||||||
|
});
|
||||||
events = await this.runControlCommand({
|
events = await this.runControlCommand({
|
||||||
args: this.buildControlArgs({
|
args: newCommand,
|
||||||
cwd,
|
|
||||||
command: [agent, "sessions", "new", "--name", sessionName],
|
|
||||||
}),
|
|
||||||
cwd,
|
cwd,
|
||||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||||
});
|
});
|
||||||
@@ -264,7 +273,7 @@ export class AcpxRuntime implements AcpRuntime {
|
|||||||
|
|
||||||
async *runTurn(input: AcpRuntimeTurnInput): AsyncIterable<AcpRuntimeEvent> {
|
async *runTurn(input: AcpRuntimeTurnInput): AsyncIterable<AcpRuntimeEvent> {
|
||||||
const state = this.resolveHandleState(input.handle);
|
const state = this.resolveHandleState(input.handle);
|
||||||
const args = this.buildPromptArgs({
|
const args = await this.buildPromptArgs({
|
||||||
agent: state.agent,
|
agent: state.agent,
|
||||||
sessionName: state.name,
|
sessionName: state.name,
|
||||||
cwd: state.cwd,
|
cwd: state.cwd,
|
||||||
@@ -381,11 +390,13 @@ export class AcpxRuntime implements AcpRuntime {
|
|||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}): Promise<AcpRuntimeStatus> {
|
}): Promise<AcpRuntimeStatus> {
|
||||||
const state = this.resolveHandleState(input.handle);
|
const state = this.resolveHandleState(input.handle);
|
||||||
|
const args = await this.buildVerbArgs({
|
||||||
|
agent: state.agent,
|
||||||
|
cwd: state.cwd,
|
||||||
|
command: ["status", "--session", state.name],
|
||||||
|
});
|
||||||
const events = await this.runControlCommand({
|
const events = await this.runControlCommand({
|
||||||
args: this.buildControlArgs({
|
args,
|
||||||
cwd: state.cwd,
|
|
||||||
command: [state.agent, "status", "--session", state.name],
|
|
||||||
}),
|
|
||||||
cwd: state.cwd,
|
cwd: state.cwd,
|
||||||
fallbackCode: "ACP_TURN_FAILED",
|
fallbackCode: "ACP_TURN_FAILED",
|
||||||
ignoreNoSession: true,
|
ignoreNoSession: true,
|
||||||
@@ -425,11 +436,13 @@ export class AcpxRuntime implements AcpRuntime {
|
|||||||
if (!mode) {
|
if (!mode) {
|
||||||
throw new AcpRuntimeError("ACP_TURN_FAILED", "ACP runtime mode is required.");
|
throw new AcpRuntimeError("ACP_TURN_FAILED", "ACP runtime mode is required.");
|
||||||
}
|
}
|
||||||
|
const args = await this.buildVerbArgs({
|
||||||
|
agent: state.agent,
|
||||||
|
cwd: state.cwd,
|
||||||
|
command: ["set-mode", mode, "--session", state.name],
|
||||||
|
});
|
||||||
await this.runControlCommand({
|
await this.runControlCommand({
|
||||||
args: this.buildControlArgs({
|
args,
|
||||||
cwd: state.cwd,
|
|
||||||
command: [state.agent, "set-mode", mode, "--session", state.name],
|
|
||||||
}),
|
|
||||||
cwd: state.cwd,
|
cwd: state.cwd,
|
||||||
fallbackCode: "ACP_TURN_FAILED",
|
fallbackCode: "ACP_TURN_FAILED",
|
||||||
});
|
});
|
||||||
@@ -446,11 +459,13 @@ export class AcpxRuntime implements AcpRuntime {
|
|||||||
if (!key || !value) {
|
if (!key || !value) {
|
||||||
throw new AcpRuntimeError("ACP_TURN_FAILED", "ACP config option key/value are required.");
|
throw new AcpRuntimeError("ACP_TURN_FAILED", "ACP config option key/value are required.");
|
||||||
}
|
}
|
||||||
|
const args = await this.buildVerbArgs({
|
||||||
|
agent: state.agent,
|
||||||
|
cwd: state.cwd,
|
||||||
|
command: ["set", key, value, "--session", state.name],
|
||||||
|
});
|
||||||
await this.runControlCommand({
|
await this.runControlCommand({
|
||||||
args: this.buildControlArgs({
|
args,
|
||||||
cwd: state.cwd,
|
|
||||||
command: [state.agent, "set", key, value, "--session", state.name],
|
|
||||||
}),
|
|
||||||
cwd: state.cwd,
|
cwd: state.cwd,
|
||||||
fallbackCode: "ACP_TURN_FAILED",
|
fallbackCode: "ACP_TURN_FAILED",
|
||||||
});
|
});
|
||||||
@@ -539,11 +554,13 @@ export class AcpxRuntime implements AcpRuntime {
|
|||||||
|
|
||||||
async cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void> {
|
async cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void> {
|
||||||
const state = this.resolveHandleState(input.handle);
|
const state = this.resolveHandleState(input.handle);
|
||||||
|
const args = await this.buildVerbArgs({
|
||||||
|
agent: state.agent,
|
||||||
|
cwd: state.cwd,
|
||||||
|
command: ["cancel", "--session", state.name],
|
||||||
|
});
|
||||||
await this.runControlCommand({
|
await this.runControlCommand({
|
||||||
args: this.buildControlArgs({
|
args,
|
||||||
cwd: state.cwd,
|
|
||||||
command: [state.agent, "cancel", "--session", state.name],
|
|
||||||
}),
|
|
||||||
cwd: state.cwd,
|
cwd: state.cwd,
|
||||||
fallbackCode: "ACP_TURN_FAILED",
|
fallbackCode: "ACP_TURN_FAILED",
|
||||||
ignoreNoSession: true,
|
ignoreNoSession: true,
|
||||||
@@ -552,11 +569,13 @@ export class AcpxRuntime implements AcpRuntime {
|
|||||||
|
|
||||||
async close(input: { handle: AcpRuntimeHandle; reason: string }): Promise<void> {
|
async close(input: { handle: AcpRuntimeHandle; reason: string }): Promise<void> {
|
||||||
const state = this.resolveHandleState(input.handle);
|
const state = this.resolveHandleState(input.handle);
|
||||||
|
const args = await this.buildVerbArgs({
|
||||||
|
agent: state.agent,
|
||||||
|
cwd: state.cwd,
|
||||||
|
command: ["sessions", "close", state.name],
|
||||||
|
});
|
||||||
await this.runControlCommand({
|
await this.runControlCommand({
|
||||||
args: this.buildControlArgs({
|
args,
|
||||||
cwd: state.cwd,
|
|
||||||
command: [state.agent, "sessions", "close", state.name],
|
|
||||||
}),
|
|
||||||
cwd: state.cwd,
|
cwd: state.cwd,
|
||||||
fallbackCode: "ACP_TURN_FAILED",
|
fallbackCode: "ACP_TURN_FAILED",
|
||||||
ignoreNoSession: true,
|
ignoreNoSession: true,
|
||||||
@@ -585,12 +604,12 @@ export class AcpxRuntime implements AcpRuntime {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildControlArgs(params: { cwd: string; command: string[] }): string[] {
|
private async buildPromptArgs(params: {
|
||||||
return ["--format", "json", "--json-strict", "--cwd", params.cwd, ...params.command];
|
agent: string;
|
||||||
}
|
sessionName: string;
|
||||||
|
cwd: string;
|
||||||
private buildPromptArgs(params: { agent: string; sessionName: string; cwd: string }): string[] {
|
}): Promise<string[]> {
|
||||||
const args = [
|
const prefix = [
|
||||||
"--format",
|
"--format",
|
||||||
"json",
|
"json",
|
||||||
"--json-strict",
|
"--json-strict",
|
||||||
@@ -601,11 +620,58 @@ export class AcpxRuntime implements AcpRuntime {
|
|||||||
this.config.nonInteractivePermissions,
|
this.config.nonInteractivePermissions,
|
||||||
];
|
];
|
||||||
if (this.config.timeoutSeconds) {
|
if (this.config.timeoutSeconds) {
|
||||||
args.push("--timeout", String(this.config.timeoutSeconds));
|
prefix.push("--timeout", String(this.config.timeoutSeconds));
|
||||||
}
|
}
|
||||||
args.push("--ttl", String(this.queueOwnerTtlSeconds));
|
prefix.push("--ttl", String(this.queueOwnerTtlSeconds));
|
||||||
args.push(params.agent, "prompt", "--session", params.sessionName, "--file", "-");
|
return await this.buildVerbArgs({
|
||||||
return args;
|
agent: params.agent,
|
||||||
|
cwd: params.cwd,
|
||||||
|
command: ["prompt", "--session", params.sessionName, "--file", "-"],
|
||||||
|
prefix,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildVerbArgs(params: {
|
||||||
|
agent: string;
|
||||||
|
cwd: string;
|
||||||
|
command: string[];
|
||||||
|
prefix?: string[];
|
||||||
|
}): Promise<string[]> {
|
||||||
|
const prefix = params.prefix ?? ["--format", "json", "--json-strict", "--cwd", params.cwd];
|
||||||
|
const agentCommand = await this.resolveRawAgentCommand({
|
||||||
|
agent: params.agent,
|
||||||
|
cwd: params.cwd,
|
||||||
|
});
|
||||||
|
if (!agentCommand) {
|
||||||
|
return [...prefix, params.agent, ...params.command];
|
||||||
|
}
|
||||||
|
return [...prefix, "--agent", agentCommand, ...params.command];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveRawAgentCommand(params: {
|
||||||
|
agent: string;
|
||||||
|
cwd: string;
|
||||||
|
}): Promise<string | null> {
|
||||||
|
if (Object.keys(this.config.mcpServers).length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const cacheKey = `${params.cwd}::${params.agent}`;
|
||||||
|
const cached = this.mcpProxyAgentCommandCache.get(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const targetCommand = await resolveAcpxAgentCommand({
|
||||||
|
acpxCommand: this.config.command,
|
||||||
|
cwd: params.cwd,
|
||||||
|
agent: params.agent,
|
||||||
|
spawnOptions: this.spawnCommandOptions,
|
||||||
|
});
|
||||||
|
const resolved = buildMcpProxyAgentCommand({
|
||||||
|
targetCommand,
|
||||||
|
mcpServers: toAcpMcpServers(this.config.mcpServers),
|
||||||
|
});
|
||||||
|
this.mcpProxyAgentCommandCache.set(cacheKey, resolved);
|
||||||
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runControlCommand(params: {
|
private async runControlCommand(params: {
|
||||||
|
|||||||
@@ -59,8 +59,9 @@ export function createAcpxRuntimeService(
|
|||||||
});
|
});
|
||||||
const expectedVersionLabel = pluginConfig.expectedVersion ?? "any";
|
const expectedVersionLabel = pluginConfig.expectedVersion ?? "any";
|
||||||
const installLabel = pluginConfig.allowPluginLocalInstall ? "enabled" : "disabled";
|
const installLabel = pluginConfig.allowPluginLocalInstall ? "enabled" : "disabled";
|
||||||
|
const mcpServerCount = Object.keys(pluginConfig.mcpServers).length;
|
||||||
ctx.logger.info(
|
ctx.logger.info(
|
||||||
`acpx runtime backend registered (command: ${pluginConfig.command}, expectedVersion: ${expectedVersionLabel}, pluginLocalInstall: ${installLabel})`,
|
`acpx runtime backend registered (command: ${pluginConfig.command}, expectedVersion: ${expectedVersionLabel}, pluginLocalInstall: ${installLabel}${mcpServerCount > 0 ? `, mcpServers: ${mcpServerCount}` : ""})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
lifecycleRevision += 1;
|
lifecycleRevision += 1;
|
||||||
|
|||||||
Reference in New Issue
Block a user