fix(qa): run gateway MCP producer from source checkout

This commit is contained in:
Vincent Koc 2026-07-05 10:51:24 +02:00
parent e7808dbe60
commit 5f2df24619
No known key found for this signature in database
2 changed files with 229 additions and 14 deletions

View file

@ -0,0 +1,99 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../../helpers/temp-dir.js";
import { testing } from "./gateway-mcp-real-transports.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
function createRepoRoot() {
return tempDirs.make("openclaw-qalab-cli-entry-");
}
async function writeEntry(root: string, relativePath: string) {
const entryPath = path.join(root, relativePath);
await mkdir(path.dirname(entryPath), { recursive: true });
await writeFile(entryPath, "", "utf8");
return entryPath;
}
describe("gateway MCP real transport producer", () => {
it("uses the source CLI entry when build output is absent", async () => {
const root = createRepoRoot();
const entryPath = await writeEntry(root, "src/entry.ts");
const cli = testing.resolveOpenClawCliInvocation(root);
expect(cli.command).toBe(process.execPath);
expect(cli.argsPrefix).toStrictEqual(["--import", "tsx", entryPath]);
expect(cli.cwd).toBe(root);
expect(cli.gatewayCommand).toMatchObject({
executablePath: process.execPath,
argsPrefix: ["--import", "tsx", entryPath],
cwd: root,
});
});
it("prefers built package output when it exists", async () => {
const root = createRepoRoot();
const distEntry = await writeEntry(root, "dist/index.mjs");
await writeEntry(root, "src/entry.ts");
const cli = testing.resolveOpenClawCliInvocation(root);
expect(cli.argsPrefix).toStrictEqual([distEntry]);
expect(cli.gatewayCommand).toMatchObject({
argsPrefix: [distEntry],
usePackagedPlugins: true,
});
});
it("uses the source channel MCP module when build output is absent", async () => {
const root = createRepoRoot();
const channelServerPath = await writeEntry(root, "src/mcp/channel-server.ts");
const mcp = testing.resolveChannelMcpInvocation({
gatewayToken: "secret-token",
gatewayUrl: "ws://127.0.0.1:12345",
repoRoot: root,
tokenFile: "/tmp/token-file",
});
expect(mcp.command).toBe(process.execPath);
expect(mcp.args.slice(0, 3)).toStrictEqual(["--import", "tsx", "--eval"]);
expect(mcp.args[3]).toContain(channelServerPath);
expect(mcp.args[3]).toContain("serveOpenClawChannelMcp");
expect(mcp.cwd).toBe(root);
expect(mcp.envPatch).toStrictEqual({
OPENCLAW_QA_GATEWAY_TOKEN: "secret-token",
OPENCLAW_QA_GATEWAY_URL: "ws://127.0.0.1:12345",
});
});
it("uses the packaged CLI for channel MCP when build output exists", async () => {
const root = createRepoRoot();
const distEntry = await writeEntry(root, "dist/index.js");
await writeEntry(root, "src/mcp/channel-server.ts");
const mcp = testing.resolveChannelMcpInvocation({
gatewayToken: "secret-token",
gatewayUrl: "ws://127.0.0.1:12345",
repoRoot: root,
tokenFile: "/tmp/token-file",
});
expect(mcp.args).toStrictEqual([
distEntry,
"mcp",
"serve",
"--url",
"ws://127.0.0.1:12345",
"--token-file",
"/tmp/token-file",
"--claude-channel-mode",
"off",
"--verbose",
]);
expect(mcp.envPatch).toStrictEqual({});
});
});

View file

@ -11,6 +11,7 @@ import { WebSocket, WebSocketServer, type RawData } from "ws";
import {
QA_EVIDENCE_FILENAME,
startQaGatewayChild,
type QaGatewayChildCommand,
type QaEvidenceSummaryJson,
type QaGatewayChildListeningContext,
} from "../../../../extensions/qa-lab/api.js";
@ -57,6 +58,20 @@ type GatewayProxy = {
url: string;
};
type OpenClawCliInvocation = {
argsPrefix: string[];
command: string;
cwd: string;
gatewayCommand: QaGatewayChildCommand;
};
type ChannelMcpInvocation = {
args: string[];
command: string;
cwd: string;
envPatch: NodeJS.ProcessEnv;
};
type McpClientHandle = {
client: Client;
cleanup: () => void;
@ -212,6 +227,104 @@ function emptyTransport() {
};
}
function resolveOpenClawCliInvocation(repoRoot: string): OpenClawCliInvocation {
for (const relativePath of ["dist/index.mjs", "dist/index.js"]) {
const entryPath = path.join(repoRoot, relativePath);
if (existsSync(entryPath)) {
const argsPrefix = [entryPath];
return {
argsPrefix,
command: process.execPath,
cwd: repoRoot,
gatewayCommand: {
executablePath: process.execPath,
argsPrefix,
cwd: repoRoot,
usePackagedPlugins: true,
},
};
}
}
const sourceEntryPath = path.join(repoRoot, "src/entry.ts");
if (existsSync(sourceEntryPath)) {
const argsPrefix = ["--import", "tsx", sourceEntryPath];
return {
argsPrefix,
command: process.execPath,
cwd: repoRoot,
gatewayCommand: {
executablePath: process.execPath,
argsPrefix,
cwd: repoRoot,
},
};
}
throw new Error("OpenClaw CLI entry not found: expected dist/index.(m)js or src/entry.ts");
}
function resolveChannelMcpInvocation(params: {
gatewayToken: string;
gatewayUrl: string;
repoRoot: string;
tokenFile: string;
}): ChannelMcpInvocation {
for (const relativePath of ["dist/index.mjs", "dist/index.js"]) {
const entryPath = path.join(params.repoRoot, relativePath);
if (existsSync(entryPath)) {
return {
args: [
entryPath,
"mcp",
"serve",
"--url",
params.gatewayUrl,
"--token-file",
params.tokenFile,
"--claude-channel-mode",
"off",
"--verbose",
],
command: process.execPath,
cwd: params.repoRoot,
envPatch: {},
};
}
}
const channelServerPath = path.join(params.repoRoot, "src/mcp/channel-server.ts");
if (existsSync(channelServerPath)) {
const channelServerUrl = pathToFileURL(channelServerPath).href;
return {
args: [
"--import",
"tsx",
"--eval",
[
`import(${JSON.stringify(channelServerUrl)})`,
`.then((module) => module.serveOpenClawChannelMcp({`,
`gatewayUrl: process.env.OPENCLAW_QA_GATEWAY_URL,`,
`gatewayToken: process.env.OPENCLAW_QA_GATEWAY_TOKEN,`,
`claudeChannelMode: "off",`,
`verbose: true`,
`}))`,
].join(""),
],
command: process.execPath,
cwd: params.repoRoot,
envPatch: {
OPENCLAW_QA_GATEWAY_TOKEN: params.gatewayToken,
OPENCLAW_QA_GATEWAY_URL: params.gatewayUrl,
},
};
}
throw new Error(
"OpenClaw channel MCP entry not found: expected dist/index.(m)js or src/mcp/channel-server.ts",
);
}
function parseJsonFrame(data: RawData): Record<string, unknown> | null {
try {
const text = Array.isArray(data)
@ -332,24 +445,20 @@ async function connectChannelMcpClient(params: {
repoRoot: string;
}): Promise<McpClientHandle> {
const tempState = createMcpClientTempState({ gatewayToken: params.gatewayToken });
const mcpInvocation = resolveChannelMcpInvocation({
gatewayToken: params.gatewayToken,
gatewayUrl: params.gatewayUrl,
repoRoot: params.repoRoot,
tokenFile: tempState.tokenFile,
});
const stderrChunks: Buffer[] = [];
const transport = new StdioClientTransport({
command: process.execPath,
args: [
path.join(params.repoRoot, "dist/index.js"),
"mcp",
"serve",
"--url",
params.gatewayUrl,
"--token-file",
tempState.tokenFile,
"--claude-channel-mode",
"off",
"--verbose",
],
cwd: params.repoRoot,
command: mcpInvocation.command,
args: mcpInvocation.args,
cwd: mcpInvocation.cwd,
env: {
...process.env,
...mcpInvocation.envPatch,
OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: "1",
OPENCLAW_LOG_LEVEL: "debug",
OPENCLAW_STATE_DIR: tempState.stateDir,
@ -416,6 +525,7 @@ async function approvePendingMcpPairing(gateway: Awaited<ReturnType<typeof start
async function runGatewaySmokeProof(options: ProducerOptions): Promise<string> {
const gateway = await startQaGatewayChild({
repoRoot: options.repoRoot,
command: resolveOpenClawCliInvocation(options.repoRoot).gatewayCommand,
transport: emptyTransport(),
transportBaseUrl: "http://127.0.0.1",
controlUiEnabled: false,
@ -475,6 +585,7 @@ async function runMcpGatewayStartupRetryProof(options: ProducerOptions): Promise
};
gateway = await startQaGatewayChild({
repoRoot: options.repoRoot,
command: resolveOpenClawCliInvocation(options.repoRoot).gatewayCommand,
transport: emptyTransport(),
transportBaseUrl: "http://127.0.0.1",
controlUiEnabled: false,
@ -699,3 +810,8 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
process.exitCode = 1;
});
}
export const testing = {
resolveChannelMcpInvocation,
resolveOpenClawCliInvocation,
};