@openclaw/acpx 2026.5.2-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +54 -0
- package/index.test.ts +119 -0
- package/index.ts +19 -0
- package/openclaw.plugin.json +170 -0
- package/package.json +38 -0
- package/register.runtime.ts +154 -0
- package/runtime-api.ts +46 -0
- package/setup-api.ts +18 -0
- package/skills/acp-router/SKILL.md +248 -0
- package/src/acpx-runtime-compat.d.ts +62 -0
- package/src/claude-agent-acp-completion.test.ts +129 -0
- package/src/codex-auth-bridge.test.ts +448 -0
- package/src/codex-auth-bridge.ts +385 -0
- package/src/config-schema.ts +117 -0
- package/src/config.test.ts +144 -0
- package/src/config.ts +273 -0
- package/src/manifest.test.ts +20 -0
- package/src/runtime-internals/error-format.mjs +6 -0
- package/src/runtime-internals/mcp-command-line.mjs +123 -0
- package/src/runtime-internals/mcp-command-line.test.ts +59 -0
- package/src/runtime-internals/mcp-proxy.mjs +113 -0
- package/src/runtime-internals/mcp-proxy.test.ts +114 -0
- package/src/runtime.test.ts +816 -0
- package/src/runtime.ts +613 -0
- package/src/service.test.ts +401 -0
- package/src/service.ts +278 -0
- package/tsconfig.json +16 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { formatPluginConfigIssue } from "openclaw/plugin-sdk/extension-shared";
|
|
6
|
+
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
|
7
|
+
import { AcpxPluginConfigSchema, DEFAULT_ACPX_TIMEOUT_SECONDS } from "./config-schema.js";
|
|
8
|
+
import type {
|
|
9
|
+
AcpxPluginConfig,
|
|
10
|
+
AcpxPermissionMode,
|
|
11
|
+
AcpxNonInteractivePermissionPolicy,
|
|
12
|
+
McpServerConfig,
|
|
13
|
+
AcpxMcpServer,
|
|
14
|
+
ResolvedAcpxPluginConfig,
|
|
15
|
+
} from "./config-schema.js";
|
|
16
|
+
export { type ResolvedAcpxPluginConfig } from "./config-schema.js";
|
|
17
|
+
|
|
18
|
+
const ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME = "openclaw-plugin-tools";
|
|
19
|
+
const ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME = "openclaw-tools";
|
|
20
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
21
|
+
|
|
22
|
+
function isAcpxPluginRoot(dir: string): boolean {
|
|
23
|
+
return (
|
|
24
|
+
fs.existsSync(path.join(dir, "openclaw.plugin.json")) &&
|
|
25
|
+
fs.existsSync(path.join(dir, "package.json"))
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function resolveNearestAcpxPluginRoot(moduleUrl: string): string {
|
|
30
|
+
let cursor = path.dirname(fileURLToPath(moduleUrl));
|
|
31
|
+
for (let i = 0; i < 3; i += 1) {
|
|
32
|
+
// Bundled entries live at the plugin root while source files still live under src/.
|
|
33
|
+
if (isAcpxPluginRoot(cursor)) {
|
|
34
|
+
return cursor;
|
|
35
|
+
}
|
|
36
|
+
const parent = path.dirname(cursor);
|
|
37
|
+
if (parent === cursor) {
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
cursor = parent;
|
|
41
|
+
}
|
|
42
|
+
return path.resolve(path.dirname(fileURLToPath(moduleUrl)), "..");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function resolveWorkspaceAcpxPluginRoot(currentRoot: string): string | null {
|
|
46
|
+
if (
|
|
47
|
+
path.basename(currentRoot) !== "acpx" ||
|
|
48
|
+
path.basename(path.dirname(currentRoot)) !== "extensions" ||
|
|
49
|
+
path.basename(path.dirname(path.dirname(currentRoot))) !== "dist"
|
|
50
|
+
) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const workspaceRoot = path.resolve(currentRoot, "..", "..", "..", "extensions", "acpx");
|
|
54
|
+
return isAcpxPluginRoot(workspaceRoot) ? workspaceRoot : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function resolveRepoAcpxPluginRoot(currentRoot: string): string | null {
|
|
58
|
+
const workspaceRoot = path.join(currentRoot, "extensions", "acpx");
|
|
59
|
+
return isAcpxPluginRoot(workspaceRoot) ? workspaceRoot : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function resolveAcpxPluginRootFromOpenClawLayout(moduleUrl: string): string | null {
|
|
63
|
+
let cursor = path.dirname(fileURLToPath(moduleUrl));
|
|
64
|
+
for (let i = 0; i < 5; i += 1) {
|
|
65
|
+
const candidates = [
|
|
66
|
+
path.join(cursor, "extensions", "acpx"),
|
|
67
|
+
path.join(cursor, "dist", "extensions", "acpx"),
|
|
68
|
+
path.join(cursor, "dist-runtime", "extensions", "acpx"),
|
|
69
|
+
];
|
|
70
|
+
for (const candidate of candidates) {
|
|
71
|
+
if (isAcpxPluginRoot(candidate)) {
|
|
72
|
+
return candidate;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const parent = path.dirname(cursor);
|
|
76
|
+
if (parent === cursor) {
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
cursor = parent;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
export function resolveAcpxPluginRoot(moduleUrl: string = import.meta.url): string {
|
|
84
|
+
const resolvedRoot = resolveNearestAcpxPluginRoot(moduleUrl);
|
|
85
|
+
// In a live repo checkout, dist/ can be rebuilt out from under the running gateway.
|
|
86
|
+
// Prefer the stable source plugin root when a built extension is running beside it.
|
|
87
|
+
return (
|
|
88
|
+
resolveWorkspaceAcpxPluginRoot(resolvedRoot) ??
|
|
89
|
+
resolveRepoAcpxPluginRoot(resolvedRoot) ??
|
|
90
|
+
// Shared dist/dist-runtime chunks can load this module outside the plugin tree.
|
|
91
|
+
// Scan common OpenClaw layouts before falling back to the nearest path guess.
|
|
92
|
+
resolveAcpxPluginRootFromOpenClawLayout(moduleUrl) ??
|
|
93
|
+
resolvedRoot
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const DEFAULT_PERMISSION_MODE: AcpxPermissionMode = "approve-reads";
|
|
98
|
+
const DEFAULT_NON_INTERACTIVE_POLICY: AcpxNonInteractivePermissionPolicy = "fail";
|
|
99
|
+
const DEFAULT_QUEUE_OWNER_TTL_SECONDS = 0.1;
|
|
100
|
+
const DEFAULT_STRICT_WINDOWS_CMD_WRAPPER = true;
|
|
101
|
+
|
|
102
|
+
type ParseResult =
|
|
103
|
+
| { ok: true; value: AcpxPluginConfig | undefined }
|
|
104
|
+
| { ok: false; message: string };
|
|
105
|
+
|
|
106
|
+
function parseAcpxPluginConfig(value: unknown): ParseResult {
|
|
107
|
+
if (value === undefined) {
|
|
108
|
+
return { ok: true, value: undefined };
|
|
109
|
+
}
|
|
110
|
+
const parsed = AcpxPluginConfigSchema.safeParse(value);
|
|
111
|
+
if (!parsed.success) {
|
|
112
|
+
return { ok: false, message: formatPluginConfigIssue(parsed.error.issues[0]) };
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
ok: true,
|
|
116
|
+
value: parsed.data as AcpxPluginConfig,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function resolveOpenClawRoot(currentRoot: string): string {
|
|
121
|
+
if (
|
|
122
|
+
path.basename(currentRoot) === "acpx" &&
|
|
123
|
+
path.basename(path.dirname(currentRoot)) === "extensions"
|
|
124
|
+
) {
|
|
125
|
+
const parent = path.dirname(path.dirname(currentRoot));
|
|
126
|
+
if (path.basename(parent) === "dist") {
|
|
127
|
+
return path.dirname(parent);
|
|
128
|
+
}
|
|
129
|
+
return parent;
|
|
130
|
+
}
|
|
131
|
+
return path.resolve(currentRoot, "..");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function resolveTsxImportSpecifier(): string {
|
|
135
|
+
try {
|
|
136
|
+
return requireFromHere.resolve("tsx");
|
|
137
|
+
} catch {
|
|
138
|
+
return "tsx";
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function resolvePluginToolsMcpServerConfig(moduleUrl: string = import.meta.url): McpServerConfig {
|
|
143
|
+
const pluginRoot = resolveAcpxPluginRoot(moduleUrl);
|
|
144
|
+
const openClawRoot = resolveOpenClawRoot(pluginRoot);
|
|
145
|
+
const distEntry = path.join(openClawRoot, "dist", "mcp", "plugin-tools-serve.js");
|
|
146
|
+
if (fs.existsSync(distEntry)) {
|
|
147
|
+
return {
|
|
148
|
+
command: process.execPath,
|
|
149
|
+
args: [distEntry],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
const sourceEntry = path.join(openClawRoot, "src", "mcp", "plugin-tools-serve.ts");
|
|
153
|
+
return {
|
|
154
|
+
command: process.execPath,
|
|
155
|
+
args: ["--import", resolveTsxImportSpecifier(), sourceEntry],
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function resolveOpenClawToolsMcpServerConfig(moduleUrl: string = import.meta.url): McpServerConfig {
|
|
160
|
+
const pluginRoot = resolveAcpxPluginRoot(moduleUrl);
|
|
161
|
+
const openClawRoot = resolveOpenClawRoot(pluginRoot);
|
|
162
|
+
const distEntry = path.join(openClawRoot, "dist", "mcp", "openclaw-tools-serve.js");
|
|
163
|
+
if (fs.existsSync(distEntry)) {
|
|
164
|
+
return {
|
|
165
|
+
command: process.execPath,
|
|
166
|
+
args: [distEntry],
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
const sourceEntry = path.join(openClawRoot, "src", "mcp", "openclaw-tools-serve.ts");
|
|
170
|
+
return {
|
|
171
|
+
command: process.execPath,
|
|
172
|
+
args: ["--import", resolveTsxImportSpecifier(), sourceEntry],
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function resolveConfiguredMcpServers(params: {
|
|
177
|
+
mcpServers?: Record<string, McpServerConfig>;
|
|
178
|
+
pluginToolsMcpBridge: boolean;
|
|
179
|
+
openClawToolsMcpBridge: boolean;
|
|
180
|
+
moduleUrl?: string;
|
|
181
|
+
}): Record<string, McpServerConfig> {
|
|
182
|
+
const resolved = { ...params.mcpServers };
|
|
183
|
+
if (params.pluginToolsMcpBridge && resolved[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME]) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`mcpServers.${ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME} is reserved when pluginToolsMcpBridge=true`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
if (params.openClawToolsMcpBridge && resolved[ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME]) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`mcpServers.${ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME} is reserved when openClawToolsMcpBridge=true`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (params.pluginToolsMcpBridge) {
|
|
194
|
+
resolved[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME] = resolvePluginToolsMcpServerConfig(
|
|
195
|
+
params.moduleUrl,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
if (params.openClawToolsMcpBridge) {
|
|
199
|
+
resolved[ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME] = resolveOpenClawToolsMcpServerConfig(
|
|
200
|
+
params.moduleUrl,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
return resolved;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function toAcpMcpServers(mcpServers: Record<string, McpServerConfig>): AcpxMcpServer[] {
|
|
207
|
+
return Object.entries(mcpServers).map(([name, server]) => ({
|
|
208
|
+
name,
|
|
209
|
+
command: server.command,
|
|
210
|
+
args: [...(server.args ?? [])],
|
|
211
|
+
env: Object.entries(server.env ?? {}).map(([envName, value]) => ({
|
|
212
|
+
name: envName,
|
|
213
|
+
value,
|
|
214
|
+
})),
|
|
215
|
+
}));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function resolveAcpxPluginConfig(params: {
|
|
219
|
+
rawConfig: unknown;
|
|
220
|
+
workspaceDir?: string;
|
|
221
|
+
moduleUrl?: string;
|
|
222
|
+
}): ResolvedAcpxPluginConfig {
|
|
223
|
+
const parsed = parseAcpxPluginConfig(params.rawConfig);
|
|
224
|
+
if (!parsed.ok) {
|
|
225
|
+
throw new Error(parsed.message);
|
|
226
|
+
}
|
|
227
|
+
const normalized = parsed.value ?? {};
|
|
228
|
+
const workspaceDir = params.workspaceDir?.trim() || process.cwd();
|
|
229
|
+
const fallbackCwd = workspaceDir;
|
|
230
|
+
const cwd = path.resolve(normalized.cwd?.trim() || fallbackCwd);
|
|
231
|
+
const stateDir = path.resolve(normalized.stateDir?.trim() || path.join(workspaceDir, "state"));
|
|
232
|
+
const pluginToolsMcpBridge = normalized.pluginToolsMcpBridge === true;
|
|
233
|
+
const openClawToolsMcpBridge = normalized.openClawToolsMcpBridge === true;
|
|
234
|
+
const mcpServers = resolveConfiguredMcpServers({
|
|
235
|
+
mcpServers: normalized.mcpServers,
|
|
236
|
+
pluginToolsMcpBridge,
|
|
237
|
+
openClawToolsMcpBridge,
|
|
238
|
+
moduleUrl: params.moduleUrl,
|
|
239
|
+
});
|
|
240
|
+
const agents = Object.fromEntries(
|
|
241
|
+
Object.entries(normalized.agents ?? {}).map(([name, entry]) => [
|
|
242
|
+
normalizeLowercaseStringOrEmpty(name),
|
|
243
|
+
entry.command.trim(),
|
|
244
|
+
]),
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
// Lowercase probeAgent so lookups match the registry keys built above, which
|
|
248
|
+
// also go through normalizeLowercaseStringOrEmpty. Without this, a user who
|
|
249
|
+
// writes `probeAgent: "OpenCode"` would silently miss the stored "opencode"
|
|
250
|
+
// key.
|
|
251
|
+
const probeAgent = normalizeLowercaseStringOrEmpty(normalized.probeAgent) || undefined;
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
cwd,
|
|
255
|
+
stateDir,
|
|
256
|
+
probeAgent,
|
|
257
|
+
permissionMode: normalized.permissionMode ?? DEFAULT_PERMISSION_MODE,
|
|
258
|
+
nonInteractivePermissions:
|
|
259
|
+
normalized.nonInteractivePermissions ?? DEFAULT_NON_INTERACTIVE_POLICY,
|
|
260
|
+
pluginToolsMcpBridge,
|
|
261
|
+
openClawToolsMcpBridge,
|
|
262
|
+
strictWindowsCmdWrapper:
|
|
263
|
+
normalized.strictWindowsCmdWrapper ?? DEFAULT_STRICT_WINDOWS_CMD_WRAPPER,
|
|
264
|
+
timeoutSeconds: normalized.timeoutSeconds ?? DEFAULT_ACPX_TIMEOUT_SECONDS,
|
|
265
|
+
queueOwnerTtlSeconds: normalized.queueOwnerTtlSeconds ?? DEFAULT_QUEUE_OWNER_TTL_SECONDS,
|
|
266
|
+
legacyCompatibilityConfig: {
|
|
267
|
+
strictWindowsCmdWrapper: normalized.strictWindowsCmdWrapper,
|
|
268
|
+
queueOwnerTtlSeconds: normalized.queueOwnerTtlSeconds,
|
|
269
|
+
},
|
|
270
|
+
mcpServers,
|
|
271
|
+
agents,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
type AcpxPackageManifest = {
|
|
5
|
+
dependencies?: Record<string, string>;
|
|
6
|
+
devDependencies?: Record<string, string>;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
describe("acpx package manifest", () => {
|
|
10
|
+
it("keeps runtime dependencies in the package manifest", () => {
|
|
11
|
+
const packageJson = JSON.parse(
|
|
12
|
+
fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
|
13
|
+
) as AcpxPackageManifest;
|
|
14
|
+
|
|
15
|
+
expect(packageJson.dependencies?.acpx).toBeDefined();
|
|
16
|
+
expect(packageJson.dependencies?.["@zed-industries/codex-acp"]).toBe("0.12.0");
|
|
17
|
+
expect(packageJson.dependencies?.["@agentclientprotocol/claude-agent-acp"]).toBe("0.31.4");
|
|
18
|
+
expect(packageJson.devDependencies?.["@agentclientprotocol/claude-agent-acp"]).toBeUndefined();
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
const WINDOWS_DIRECT_EXECUTABLE_PATH_RE =
|
|
2
|
+
/^(?<command>(?:[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+[\\/]).*?\.(?:exe|com))(?=\s|$)(?:\s+(?<rest>.*))?$/i;
|
|
3
|
+
|
|
4
|
+
// Windows wrapper scripts need their host shell or interpreter (`cmd.exe`,
|
|
5
|
+
// `powershell.exe`, or `node`) instead of direct spawning.
|
|
6
|
+
const WINDOWS_WRAPPER_PATH_RE =
|
|
7
|
+
/^(?:[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+[\\/]).*?\.(?:bat|cmd|cjs|js|mjs|ps1)$/i;
|
|
8
|
+
|
|
9
|
+
function splitCommandParts(value, platform = process.platform) {
|
|
10
|
+
const parts = [];
|
|
11
|
+
let current = "";
|
|
12
|
+
let quote = null;
|
|
13
|
+
let escaping = false;
|
|
14
|
+
|
|
15
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
16
|
+
const ch = value[index];
|
|
17
|
+
const next = value[index + 1];
|
|
18
|
+
if (escaping) {
|
|
19
|
+
current += ch;
|
|
20
|
+
escaping = false;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (ch === "\\") {
|
|
24
|
+
if (quote === "'") {
|
|
25
|
+
current += ch;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (platform === "win32") {
|
|
29
|
+
if (quote === '"') {
|
|
30
|
+
if (next === '"' || next === "\\") {
|
|
31
|
+
escaping = true;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
current += ch;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (!quote) {
|
|
38
|
+
current += ch;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
escaping = true;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (quote) {
|
|
46
|
+
if (ch === quote) {
|
|
47
|
+
quote = null;
|
|
48
|
+
} else {
|
|
49
|
+
current += ch;
|
|
50
|
+
}
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (ch === "'" || ch === '"') {
|
|
54
|
+
quote = ch;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (/\s/.test(ch)) {
|
|
58
|
+
if (current.length > 0) {
|
|
59
|
+
parts.push(current);
|
|
60
|
+
current = "";
|
|
61
|
+
}
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
current += ch;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (escaping) {
|
|
68
|
+
current += "\\";
|
|
69
|
+
}
|
|
70
|
+
if (quote) {
|
|
71
|
+
throw new Error("Invalid agent command: unterminated quote");
|
|
72
|
+
}
|
|
73
|
+
if (current.length > 0) {
|
|
74
|
+
parts.push(current);
|
|
75
|
+
}
|
|
76
|
+
return parts;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function splitWindowsExecutableCommand(value, platform = process.platform) {
|
|
80
|
+
if (platform !== "win32") {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const trimmed = value.trim();
|
|
84
|
+
if (!trimmed || trimmed.startsWith('"') || trimmed.startsWith("'")) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const match = trimmed.match(WINDOWS_DIRECT_EXECUTABLE_PATH_RE);
|
|
88
|
+
if (!match?.groups?.command) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const rest = match.groups.rest?.trim() ?? "";
|
|
92
|
+
return {
|
|
93
|
+
command: match.groups.command,
|
|
94
|
+
args: rest ? splitCommandParts(rest, platform) : [],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function assertSupportedWindowsCommand(command, platform = process.platform) {
|
|
99
|
+
if (platform !== "win32" || !WINDOWS_WRAPPER_PATH_RE.test(command)) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
throw new Error(
|
|
103
|
+
`Unsupported Windows agent command wrapper: ${command}. ` +
|
|
104
|
+
"Invoke wrapper scripts through their shell or interpreter instead " +
|
|
105
|
+
"(for example `cmd.exe /c`, `powershell.exe -File`, or `node <script>`).",
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function splitCommandLine(value, platform = process.platform) {
|
|
110
|
+
const windowsCommand = splitWindowsExecutableCommand(value, platform);
|
|
111
|
+
const parts = windowsCommand ?? splitCommandParts(value, platform);
|
|
112
|
+
if (parts.length === 0) {
|
|
113
|
+
throw new Error("Invalid agent command: empty command");
|
|
114
|
+
}
|
|
115
|
+
const parsed = Array.isArray(parts)
|
|
116
|
+
? {
|
|
117
|
+
command: parts[0],
|
|
118
|
+
args: parts.slice(1),
|
|
119
|
+
}
|
|
120
|
+
: parts;
|
|
121
|
+
assertSupportedWindowsCommand(parsed.command, platform);
|
|
122
|
+
return parsed;
|
|
123
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
type SplitCommandLine = (
|
|
4
|
+
value: string,
|
|
5
|
+
platform?: string,
|
|
6
|
+
) => {
|
|
7
|
+
command: string;
|
|
8
|
+
args: string[];
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
async function loadSplitCommandLine(): Promise<SplitCommandLine> {
|
|
12
|
+
const moduleUrl = new URL("./mcp-command-line.mjs", import.meta.url);
|
|
13
|
+
return (await import(moduleUrl.href)).splitCommandLine as SplitCommandLine;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("mcp-command-line", () => {
|
|
17
|
+
it("parses quoted Windows executable paths without dropping backslashes", async () => {
|
|
18
|
+
const splitCommandLine = await loadSplitCommandLine();
|
|
19
|
+
const parsed = splitCommandLine(
|
|
20
|
+
'"C:\\Program Files\\Claude\\claude.exe" --stdio --flag "two words"',
|
|
21
|
+
"win32",
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
expect(parsed).toEqual({
|
|
25
|
+
command: "C:\\Program Files\\Claude\\claude.exe",
|
|
26
|
+
args: ["--stdio", "--flag", "two words"],
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("parses unquoted Windows executable paths without mangling backslashes", async () => {
|
|
31
|
+
const splitCommandLine = await loadSplitCommandLine();
|
|
32
|
+
const parsed = splitCommandLine("C:\\Users\\alerl\\.local\\bin\\claude.exe --version", "win32");
|
|
33
|
+
|
|
34
|
+
expect(parsed).toEqual({
|
|
35
|
+
command: "C:\\Users\\alerl\\.local\\bin\\claude.exe",
|
|
36
|
+
args: ["--version"],
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("preserves unquoted Windows path arguments after the executable", async () => {
|
|
41
|
+
const splitCommandLine = await loadSplitCommandLine();
|
|
42
|
+
const parsed = splitCommandLine(
|
|
43
|
+
'"C:\\Program Files\\Claude\\claude.exe" --config C:\\Users\\me\\cfg.json',
|
|
44
|
+
"win32",
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
expect(parsed).toEqual({
|
|
48
|
+
command: "C:\\Program Files\\Claude\\claude.exe",
|
|
49
|
+
args: ["--config", "C:\\Users\\me\\cfg.json"],
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("rejects direct Windows wrapper-script commands with a helpful error", async () => {
|
|
54
|
+
const splitCommandLine = await loadSplitCommandLine();
|
|
55
|
+
expect(() =>
|
|
56
|
+
splitCommandLine('"C:\\Users\\me\\bin\\claude-wrapper.cmd" --stdio', "win32"),
|
|
57
|
+
).toThrow(/Invoke wrapper scripts through their shell or interpreter instead/);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
import { formatErrorMessage } from "./error-format.mjs";
|
|
8
|
+
import { splitCommandLine } from "./mcp-command-line.mjs";
|
|
9
|
+
|
|
10
|
+
function decodePayload(argv) {
|
|
11
|
+
const payloadIndex = argv.indexOf("--payload");
|
|
12
|
+
if (payloadIndex < 0) {
|
|
13
|
+
throw new Error("Missing --payload");
|
|
14
|
+
}
|
|
15
|
+
const encoded = argv[payloadIndex + 1];
|
|
16
|
+
if (!encoded) {
|
|
17
|
+
throw new Error("Missing MCP proxy payload value");
|
|
18
|
+
}
|
|
19
|
+
const parsed = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
|
|
20
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
21
|
+
throw new Error("Invalid MCP proxy payload");
|
|
22
|
+
}
|
|
23
|
+
if (typeof parsed.targetCommand !== "string" || parsed.targetCommand.trim() === "") {
|
|
24
|
+
throw new Error("MCP proxy payload missing targetCommand");
|
|
25
|
+
}
|
|
26
|
+
const mcpServers = Array.isArray(parsed.mcpServers) ? parsed.mcpServers : [];
|
|
27
|
+
return {
|
|
28
|
+
targetCommand: parsed.targetCommand,
|
|
29
|
+
mcpServers,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function shouldInject(method) {
|
|
34
|
+
return method === "session/new" || method === "session/load" || method === "session/fork";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function rewriteLine(line, mcpServers) {
|
|
38
|
+
if (!line.trim()) {
|
|
39
|
+
return line;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(line);
|
|
43
|
+
if (
|
|
44
|
+
!parsed ||
|
|
45
|
+
typeof parsed !== "object" ||
|
|
46
|
+
Array.isArray(parsed) ||
|
|
47
|
+
!shouldInject(parsed.method) ||
|
|
48
|
+
!parsed.params ||
|
|
49
|
+
typeof parsed.params !== "object" ||
|
|
50
|
+
Array.isArray(parsed.params)
|
|
51
|
+
) {
|
|
52
|
+
return line;
|
|
53
|
+
}
|
|
54
|
+
const next = {
|
|
55
|
+
...parsed,
|
|
56
|
+
params: {
|
|
57
|
+
...parsed.params,
|
|
58
|
+
mcpServers,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
return JSON.stringify(next);
|
|
62
|
+
} catch {
|
|
63
|
+
return line;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isMainModule() {
|
|
68
|
+
const mainPath = process.argv[1];
|
|
69
|
+
if (!mainPath) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
return import.meta.url === pathToFileURL(path.resolve(mainPath)).href;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function main() {
|
|
76
|
+
const { targetCommand, mcpServers } = decodePayload(process.argv.slice(2));
|
|
77
|
+
const target = splitCommandLine(targetCommand);
|
|
78
|
+
const child = spawn(target.command, target.args, {
|
|
79
|
+
stdio: ["pipe", "pipe", "inherit"],
|
|
80
|
+
env: process.env,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (!child.stdin || !child.stdout) {
|
|
84
|
+
throw new Error("Failed to create MCP proxy stdio pipes");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const input = createInterface({ input: process.stdin });
|
|
88
|
+
input.on("line", (line) => {
|
|
89
|
+
child.stdin.write(`${rewriteLine(line, mcpServers)}\n`);
|
|
90
|
+
});
|
|
91
|
+
input.on("close", () => {
|
|
92
|
+
child.stdin.end();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
child.stdout.pipe(process.stdout);
|
|
96
|
+
|
|
97
|
+
child.on("error", (error) => {
|
|
98
|
+
process.stderr.write(`${formatErrorMessage(error)}\n`);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
child.on("close", (code, signal) => {
|
|
103
|
+
if (signal) {
|
|
104
|
+
process.kill(process.pid, signal);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
process.exit(code ?? 0);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (isMainModule()) {
|
|
112
|
+
main();
|
|
113
|
+
}
|