openclaw / scripts /agent-plugin-gateway-e2e.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
87dab07 verified
Raw
History Blame Contribute Delete
17.4 kB
import fs from "node:fs/promises";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { setTimeout as delay } from "node:timers/promises";
import { formatErrorMessage } from "@openclaw/normalization-core/error-coercion";
import { applyMockOpenAiModelConfig } from "./e2e/lib/fixtures/mock-openai-config.mjs";
import {
hasUnjoinedWork,
runManagedCommand,
signalExitCode,
} from "./lib/managed-child-process.mts";
const LABEL = "agent-plugin-gateway-e2e";
const PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
const MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
const GATEWAY_TOKEN = "agent-plugin-gateway-e2e";
const MAX_LOG_BYTES = 128 * 1024;
type ChildOutcome = { code: number } | { error: unknown };
type CapturedChild = {
label: string;
output: { stderr: string; stdout: string };
completion: Promise<ChildOutcome>;
stop(): void;
readonly outcome: ChildOutcome | undefined;
};
type ResponsesPayload = {
output?: Array<{
content?: Array<{ text?: string; type?: string }>;
type?: string;
}>;
};
type E2eConfig = Record<string, unknown> & {
agents?: Record<string, unknown> & { defaults?: Record<string, unknown> };
gateway?: Record<string, unknown>;
tools?: Record<string, unknown>;
};
function appendBounded(current: string, chunk: Buffer | string): string {
const next = `${current}${chunk.toString()}`;
return next.length <= MAX_LOG_BYTES ? next : next.slice(-MAX_LOG_BYTES);
}
async function freePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
server.close((error) => (error ? reject(error) : resolve(port)));
});
});
}
function startCaptured(
command: string,
args: string[],
options: {
cwd: string;
env: NodeJS.ProcessEnv;
label: string;
signal: AbortSignal;
onSignal: (received: NodeJS.Signals) => void;
timeoutMs?: number;
},
): CapturedChild {
const output = { stderr: "", stdout: "" };
const stop = new AbortController();
let outcome: ChildOutcome | undefined;
const completion = runManagedCommand({
bin: command,
args,
cwd: options.cwd,
env: options.env,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
signal: AbortSignal.any([options.signal, stop.signal]),
onSignal: options.onSignal,
timeoutMs: options.timeoutMs,
timeoutKillGraceMs: 2_000,
signalKillGraceMs: 2_000,
abortKillGraceMs: 2_000,
cleanupDrainTimeoutMs: 1_000,
requireProcessTreeExit: process.platform !== "win32",
onReady(child) {
child.stdout?.on("data", (chunk: Buffer) => {
output.stdout = appendBounded(output.stdout, chunk);
});
child.stderr?.on("data", (chunk: Buffer) => {
output.stderr = appendBounded(output.stderr, chunk);
});
},
}).then(
(code) =>
(outcome =
code === 0 ? { code } : { error: childFailure({ label: options.label, output }, code) }),
(error: unknown) => (outcome = { error }),
);
return {
label: options.label,
output,
completion,
stop: () => stop.abort(),
get outcome() {
return outcome;
},
};
}
function childFailure(child: Pick<CapturedChild, "label" | "output">, code: number) {
return new Error(
`${child.label} failed (exit ${code})\n` +
(child.output.stderr || child.output.stdout || "<no output>"),
);
}
function assertChildRunning(child: CapturedChild, signal: AbortSignal): void {
signal.throwIfAborted();
if (child.outcome) {
throw "error" in child.outcome ? child.outcome.error : childFailure(child, child.outcome.code);
}
}
async function waitForHttp(
url: string,
child: CapturedChild,
signal: AbortSignal,
timeoutMs = 60_000,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
assertChildRunning(child, signal);
try {
const response = await fetch(url, {
signal: AbortSignal.any([signal, AbortSignal.timeout(1_000)]),
});
signal.throwIfAborted();
if (response.ok) {
return;
}
} catch {
signal.throwIfAborted();
// The service is still starting.
}
await delay(100, undefined, { signal });
}
throw new Error(`${child.label} did not become ready at ${url}\n${child.output.stderr}`);
}
async function waitForOutputLine(
child: CapturedChild,
predicate: (line: string) => boolean,
signal: AbortSignal,
timeoutMs = 30_000,
): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
assertChildRunning(child, signal);
const line = `${child.output.stdout}\n${child.output.stderr}`.split(/\r?\n/u).find(predicate);
if (line) {
return line;
}
await delay(50, undefined, { signal });
}
throw new Error(`${child.label} did not emit the expected output\n${child.output.stderr}`);
}
async function writeFixture(pluginRoot: string): Promise<void> {
const skillDir = path.join(pluginRoot, "skills", "forecast-brief");
await fs.mkdir(skillDir, { recursive: true });
await fs.writeFile(
path.join(pluginRoot, "plugin.json"),
`${JSON.stringify(
{
$schema: PLUGIN_SCHEMA,
name: "weather-helper",
extensions: {
"ai.openclaw": { activation: { onStartup: true } },
"com.example.other": { ignored: true },
},
},
null,
2,
)}\n`,
);
await fs.writeFile(
path.join(skillDir, "SKILL.md"),
"---\nname: forecast-brief\ndescription: Summarize a weather forecast.\n---\n\nUse the weather probe when asked for a forecast.\n",
);
await fs.writeFile(
path.join(pluginRoot, "mcp.json"),
`${JSON.stringify(
{
$schema: MCP_SCHEMA,
mcpServers: {
"weather-probe": {
type: "stdio",
command: "node",
args: ["${PLUGIN_ROOT}/server.mjs"],
env: { PROBE_MODE: "live" },
cwd: "${PLUGIN_DATA}",
},
},
},
null,
2,
)}\n`,
);
await fs.writeFile(
path.join(pluginRoot, "server.mjs"),
`import fs from "node:fs";
import path from "node:path";
const pluginData = process.env.PLUGIN_DATA ?? "";
fs.writeFileSync(
path.join(pluginData, "probe-launch.txt"),
JSON.stringify({
argv: process.argv.slice(2),
cwd: process.cwd(),
pluginData,
pluginRoot: process.env.PLUGIN_ROOT,
}),
"utf8",
);
let buffer = "";
function send(message) {
process.stdout.write(JSON.stringify(message) + "\\n");
}
function handle(message) {
if (message.method === "initialize") {
send({
jsonrpc: "2.0",
id: message.id,
result: {
protocolVersion: message.params?.protocolVersion ?? "2025-11-25",
capabilities: { tools: {} },
serverInfo: { name: "weather-probe", version: "1.0.0" },
},
});
return;
}
if (message.method === "notifications/initialized") return;
if (message.method === "tools/list") {
send({
jsonrpc: "2.0",
id: message.id,
result: {
tools: [{
name: "weather_probe",
description: "Reports the Agent Plugins subprocess environment contract.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
}],
},
});
return;
}
if (message.method === "tools/call") {
const text = [
"probe ok",
"PLUGIN_ROOT=" + process.env.PLUGIN_ROOT,
"PLUGIN_DATA=" + process.env.PLUGIN_DATA,
"PROBE_MODE=" + process.env.PROBE_MODE,
].join("; ");
send({
jsonrpc: "2.0",
id: message.id,
result: { content: [{ type: "text", text }], isError: false },
});
}
}
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
buffer += chunk;
while (true) {
const newline = buffer.indexOf("\\n");
if (newline < 0) break;
const line = buffer.slice(0, newline).replace(/\\r$/, "");
buffer = buffer.slice(newline + 1);
if (line.trim()) handle(JSON.parse(line));
}
});
process.stdin.on("end", () => process.exit(0));
process.on("SIGTERM", () => process.exit(0));
process.on("SIGINT", () => process.exit(0));
`,
);
}
async function writeConfig(params: {
configPath: string;
gatewayPort: number;
mockPort: number;
workspaceDir: string;
}): Promise<void> {
const installedConfig = JSON.parse(await fs.readFile(params.configPath, "utf8")) as E2eConfig;
const cfg: E2eConfig = {
...installedConfig,
agents: {
...installedConfig.agents,
defaults: { ...installedConfig.agents?.defaults, workspace: params.workspaceDir },
},
gateway: {
...installedConfig.gateway,
mode: "local",
bind: "loopback",
port: params.gatewayPort,
auth: { mode: "token", token: GATEWAY_TOKEN },
controlUi: { enabled: false },
http: { endpoints: { responses: { enabled: true } } },
},
tools: { ...installedConfig.tools, profile: "coding" },
};
applyMockOpenAiModelConfig(cfg, { mockPort: params.mockPort });
await fs.writeFile(params.configPath, `${JSON.stringify(cfg, null, 2)}\n`, "utf8");
}
function responseText(payload: unknown): string {
if (!payload || typeof payload !== "object") {
return "";
}
const output = (payload as ResponsesPayload).output;
return (output ?? [])
.flatMap((item) => item.content ?? [])
.filter((content) => content.type === "output_text" && typeof content.text === "string")
.map((content) => content.text)
.join("\n");
}
async function main() {
const cancellation = new AbortController();
const signal = cancellation.signal;
const children: CapturedChild[] = [];
const failures: unknown[] = [];
const interruption = new Error(`${LABEL} interrupted`);
const handleSignal = (received?: NodeJS.Signals) => {
if (received === "SIGHUP") {
process.exitCode = signalExitCode(received);
}
// An external signal still fails the run after normal teardown has begun.
if (!failures.includes(interruption)) {
failures.push(interruption);
}
cancellation.abort(interruption);
};
process.on("SIGINT", handleSignal);
process.on("SIGTERM", handleSignal);
let rootDir: string | undefined;
const keep = process.env.OPENCLAW_AGENT_PLUGIN_GATEWAY_E2E_KEEP === "1";
const repoRoot = path.resolve(import.meta.dirname, "..");
const devRunnerPath = path.join(repoRoot, "scripts", "run-node.mjs");
const entryPath = path.join(repoRoot, "dist", "index.js");
const result = await (async () => {
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-plugin-gateway-"));
rootDir = await fs.realpath(rootDir);
const stateDir = path.join(rootDir, "state");
const configPath = path.join(stateDir, "openclaw.json");
const fixtureDir = path.join(rootDir, "weather-helper");
const workspaceDir = path.join(rootDir, "workspace");
const mockPort = await freePort();
let gatewayPort = await freePort();
while (gatewayPort === mockPort) {
signal.throwIfAborted();
gatewayPort = await freePort();
}
signal.throwIfAborted();
const childEnv: NodeJS.ProcessEnv = {
...process.env,
OPENAI_API_KEY: "agent-plugin-gateway-e2e",
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_NO_RESPAWN: "1",
OPENCLAW_SKIP_CHANNELS: "1",
OPENCLAW_SKIP_STARTUP_MODEL_PREWARM: "1",
OPENCLAW_STATE_DIR: stateDir,
};
await fs.mkdir(stateDir, { recursive: true });
await fs.mkdir(workspaceDir, { recursive: true });
await writeFixture(fixtureDir);
const install = startCaptured(
process.execPath,
[devRunnerPath, "plugins", "install", fixtureDir, "--force", "--accept-capabilities"],
{
cwd: repoRoot,
env: childEnv,
label: "plugin install",
signal,
onSignal: handleSignal,
timeoutMs: 120_000,
},
);
children.push(install);
const installed = await install.completion;
if ("error" in installed) {
throw installed.error;
}
signal.throwIfAborted();
await writeConfig({ configPath, gatewayPort, mockPort, workspaceDir });
const mock = startCaptured(process.execPath, ["scripts/e2e/mock-openai-server.mjs"], {
cwd: repoRoot,
env: { ...childEnv, MOCK_PORT: String(mockPort) },
label: "mock OpenAI server",
signal,
onSignal: handleSignal,
});
children.push(mock);
await waitForHttp(`http://127.0.0.1:${mockPort}/health`, mock, signal);
const gateway = startCaptured(
process.execPath,
[entryPath, "gateway", "--port", String(gatewayPort), "--bind", "loopback"],
{ cwd: repoRoot, env: childEnv, label: "gateway", signal, onSignal: handleSignal },
);
children.push(gateway);
await waitForHttp(`http://127.0.0.1:${gatewayPort}/health`, gateway, signal, 120_000);
const startupLog = await waitForOutputLine(
gateway,
(line) => line.includes("http server listening (") && line.includes("weather-helper"),
signal,
);
const response = await fetch(`http://127.0.0.1:${gatewayPort}/v1/responses`, {
method: "POST",
headers: {
authorization: `Bearer ${GATEWAY_TOKEN}`,
"content-type": "application/json",
"x-openclaw-agent": "main",
"x-openclaw-scopes": "operator.write",
"x-openclaw-session-key": "agent:main:openresponses:agent-plugin-gateway-e2e",
},
body: JSON.stringify({
model: "openclaw/main",
input: "agent plugin bundle qa check",
max_output_tokens: 256,
stream: false,
}),
signal: AbortSignal.any([signal, AbortSignal.timeout(180_000)]),
});
const responseBody = await response.text();
if (!response.ok) {
throw new Error(`gateway response failed (${response.status}): ${responseBody}`);
}
const finalText = responseText(JSON.parse(responseBody) as unknown);
if (!finalText.includes("AGENT_BUNDLE_MCP_OK")) {
throw new Error(`unexpected final response: ${finalText || responseBody}`);
}
const pluginOutput = `${install.output.stdout}\n${install.output.stderr}\n${gateway.output.stdout}\n${gateway.output.stderr}`;
if (
pluginOutput.includes("com.example.other") ||
pluginOutput.includes("ignoring Agent Plugins")
) {
throw new Error(`foreign extension namespace produced plugin diagnostics:\n${pluginOutput}`);
}
const installedPlugin = await fs.realpath(path.join(stateDir, "extensions", "weather-helper"));
const pluginData = path.join(stateDir, "plugin-data", "weather-helper");
const launchMarker = path.join(pluginData, "probe-launch.txt");
const launchPayload = JSON.parse(await fs.readFile(launchMarker, "utf8")) as {
argv?: unknown;
cwd?: unknown;
pluginData?: unknown;
pluginRoot?: unknown;
};
const expectedLaunch = {
argv: [],
cwd: pluginData,
pluginData,
pluginRoot: installedPlugin,
};
if (JSON.stringify(launchPayload) !== JSON.stringify(expectedLaunch)) {
throw new Error(
`invalid probe launch contract: ${JSON.stringify({ expectedLaunch, launchPayload })}`,
);
}
signal.throwIfAborted();
return { ok: true, finalText, installedPlugin, launchMarker, startupLog };
})().then(
(value) => ({ value }),
(error: unknown) => ({ error }),
);
if ("error" in result && !failures.includes(result.error)) {
failures.push(result.error);
}
// Keep the mock available while the Gateway drains on ordinary completion or failure.
for (const child of children.toReversed()) {
child.stop();
const outcome = await child.completion;
if (
"error" in outcome &&
outcome.error !== signal.reason &&
!(
outcome.error instanceof Error &&
"code" in outcome.error &&
outcome.error.code === "ABORT_ERR"
) &&
!failures.includes(outcome.error)
) {
failures.push(outcome.error);
}
}
try {
if (rootDir) {
if (keep || failures.some(hasUnjoinedWork)) {
process.stderr.write(`[${LABEL}] retained fixture directory: ${rootDir}\n`);
} else {
await fs.rm(rootDir, { recursive: true, force: true });
}
}
} catch (error) {
failures.push(error);
} finally {
process.off("SIGINT", handleSignal);
process.off("SIGTERM", handleSignal);
}
if ("error" in result || failures.length > 0) {
throw failures.length === 1
? failures[0]
: new AggregateError(failures, `${LABEL} failed, including child cleanup`);
}
return result.value;
}
try {
process.stdout.write(`${JSON.stringify(await main(), null, 2)}\n`);
} catch (error) {
const message =
error instanceof AggregateError
? formatErrorMessage(error, { redact: (text) => text })
: error instanceof Error
? error.stack || error.message
: String(error);
const exitCode = Number(process.exitCode) || 1;
process.stderr.write(`${message}\n[${LABEL}] FAILED (exit ${exitCode})\n`);
process.exitCode = exitCode;
}