Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
128
extensions/acpx/src/runtime-internals/mcp-command-line.mjs
Normal file
128
extensions/acpx/src/runtime-internals/mcp-command-line.mjs
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Command-line parser for ACPX MCP proxy targets. It handles simple quoting and
|
||||
* Windows executable paths before spawning the configured MCP target.
|
||||
*/
|
||||
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>`).",
|
||||
);
|
||||
}
|
||||
|
||||
/** Split a configured command string into `{ command, args }` for child_process.spawn. */
|
||||
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,60 @@
|
||||
// ACPX tests cover mcp command line plugin behavior.
|
||||
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/);
|
||||
});
|
||||
});
|
||||
159
extensions/acpx/src/runtime-internals/mcp-proxy.mjs
Normal file
159
extensions/acpx/src/runtime-internals/mcp-proxy.mjs
Normal file
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Stdio MCP proxy used by ACPX wrappers. It injects OpenClaw-provided MCP
|
||||
* servers into session creation/load/fork requests before forwarding to target.
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { splitCommandLine } from "./mcp-command-line.mjs";
|
||||
|
||||
function formatErrorMessage(error) {
|
||||
if (error instanceof Error) {
|
||||
return error.message || error.name || "Error";
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build spawn options for the proxied MCP target process. */
|
||||
export function createTargetSpawnOptions(platform = process.platform) {
|
||||
const options = {
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
env: process.env,
|
||||
};
|
||||
if (platform === "win32") {
|
||||
options.windowsHide = true;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
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, createTargetSpawnOptions());
|
||||
|
||||
if (!child.stdin || !child.stdout) {
|
||||
throw new Error("Failed to create MCP proxy stdio pipes");
|
||||
}
|
||||
|
||||
const input = createInterface({ input: process.stdin });
|
||||
let exiting = false;
|
||||
|
||||
const exitWithError = (error) => {
|
||||
if (exiting) {
|
||||
return;
|
||||
}
|
||||
exiting = true;
|
||||
input.close();
|
||||
child.kill();
|
||||
process.stderr.write(`${formatErrorMessage(error)}\n`);
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
child.stdin.on("error", exitWithError);
|
||||
process.stdout.on("error", exitWithError);
|
||||
|
||||
input.on("line", (line) => {
|
||||
if (exiting) {
|
||||
return;
|
||||
}
|
||||
child.stdin.write(`${rewriteLine(line, mcpServers)}\n`, (error) => {
|
||||
if (error) {
|
||||
exitWithError(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
input.on("close", () => {
|
||||
if (exiting || child.stdin.destroyed || child.stdin.writableEnded) {
|
||||
return;
|
||||
}
|
||||
child.stdin.end();
|
||||
});
|
||||
|
||||
child.stdout.pipe(process.stdout);
|
||||
|
||||
child.on("error", exitWithError);
|
||||
|
||||
child.on("close", (code, signal) => {
|
||||
if (exiting) {
|
||||
return;
|
||||
}
|
||||
exiting = true;
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
if (isMainModule()) {
|
||||
main();
|
||||
}
|
||||
241
extensions/acpx/src/runtime-internals/mcp-proxy.test.ts
Normal file
241
extensions/acpx/src/runtime-internals/mcp-proxy.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
// ACPX tests cover mcp proxy plugin behavior.
|
||||
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 { pathToFileURL } from "node:url";
|
||||
import { bundledPluginFile } from "openclaw/plugin-sdk/test-fixtures";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const proxyPath = path.resolve(bundledPluginFile("acpx", "src/runtime-internals/mcp-proxy.mjs"));
|
||||
|
||||
function encodePayload(payload: Record<string, unknown>): string {
|
||||
return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
||||
}
|
||||
|
||||
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("hides the target MCP process window on Windows only", async () => {
|
||||
const moduleUrl = pathToFileURL(proxyPath).href;
|
||||
const { createTargetSpawnOptions } = (await import(moduleUrl)) as {
|
||||
createTargetSpawnOptions: (platform?: NodeJS.Platform) => Record<string, unknown>;
|
||||
};
|
||||
|
||||
expect(createTargetSpawnOptions("win32")).toEqual({
|
||||
env: process.env,
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
windowsHide: true,
|
||||
});
|
||||
expect(createTargetSpawnOptions("darwin")).not.toHaveProperty("windowsHide");
|
||||
expect(createTargetSpawnOptions("linux")).not.toHaveProperty("windowsHide");
|
||||
});
|
||||
|
||||
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 = encodePayload({
|
||||
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" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it("reports target stdin pipe failures without an unhandled stream error", async () => {
|
||||
const closedStdinServerPath = await makeTempScript(
|
||||
"closed-stdin-server.cjs",
|
||||
String.raw`#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
fs.closeSync(0);
|
||||
process.stdout.write("ready\n");
|
||||
setTimeout(() => {}, 30_000);
|
||||
`,
|
||||
);
|
||||
|
||||
const payload = encodePayload({
|
||||
targetCommand: `${process.execPath} ${closedStdinServerPath}`,
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
const child = spawn(process.execPath, [proxyPath, "--payload", payload], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
if (stdout.includes("ready\n")) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
|
||||
await ready;
|
||||
child.stdin.write(
|
||||
`${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "session/new",
|
||||
params: { cwd: process.cwd(), mcpServers: [] },
|
||||
})}\n`,
|
||||
);
|
||||
child.stdin.end();
|
||||
|
||||
const exitCode = await new Promise<number | null>((resolve) => {
|
||||
child.once("close", (code) => resolve(code));
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toMatch(/EPIPE|write/i);
|
||||
expect(stderr).not.toContain("Unhandled 'error' event");
|
||||
});
|
||||
|
||||
it("reports proxy stdout pipe failures without an unhandled stream error", async () => {
|
||||
const outputServerPath = await makeTempScript(
|
||||
"output-server.cjs",
|
||||
String.raw`#!/usr/bin/env node
|
||||
const { createInterface } = require("node:readline");
|
||||
process.stderr.write("ready\n");
|
||||
createInterface({ input: process.stdin }).once("line", () => {
|
||||
process.stdout.write("x".repeat(1024 * 1024));
|
||||
});
|
||||
setTimeout(() => {}, 30_000);
|
||||
`,
|
||||
);
|
||||
|
||||
const payload = encodePayload({
|
||||
targetCommand: `${process.execPath} ${outputServerPath}`,
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
const child = spawn(process.execPath, [proxyPath, "--payload", payload], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
let stderr = "";
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
if (stderr.includes("ready\n")) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await ready;
|
||||
child.stdout.destroy();
|
||||
child.stdin.write(
|
||||
`${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "session/new",
|
||||
params: { cwd: process.cwd(), mcpServers: [] },
|
||||
})}\n`,
|
||||
);
|
||||
child.stdin.end();
|
||||
|
||||
const exitCode = await new Promise<number | null>((resolve) => {
|
||||
child.once("close", (code) => resolve(code));
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toMatch(/EPIPE|write/i);
|
||||
expect(stderr).not.toContain("Unhandled 'error' event");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user