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; stop(): void; readonly outcome: ChildOutcome | undefined; }; type ResponsesPayload = { output?: Array<{ content?: Array<{ text?: string; type?: string }>; type?: string; }>; }; type E2eConfig = Record & { agents?: Record & { defaults?: Record }; gateway?: Record; tools?: Record; }; 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 { 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, code: number) { return new Error( `${child.label} failed (exit ${code})\n` + (child.output.stderr || child.output.stdout || ""), ); } 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 { 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 { 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 { 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 { 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; }