From 85d2a9ec1f1aac5160cd22cf6a56b531eeda7067 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Apr 2026 23:12:06 +0100 Subject: [PATCH] test(cron): add docker mcp cleanup e2e --- package.json | 3 +- scripts/e2e/cron-mcp-cleanup-docker-client.ts | 148 ++++++++++++++++++ scripts/e2e/cron-mcp-cleanup-docker.sh | 89 +++++++++++ scripts/e2e/cron-mcp-cleanup-seed.ts | 134 ++++++++++++++++ scripts/e2e/mcp-channels-harness.ts | 2 +- 5 files changed, 374 insertions(+), 2 deletions(-) create mode 100644 scripts/e2e/cron-mcp-cleanup-docker-client.ts create mode 100644 scripts/e2e/cron-mcp-cleanup-docker.sh create mode 100644 scripts/e2e/cron-mcp-cleanup-seed.ts diff --git a/package.json b/package.json index a607c84db6b..b3b6d004855 100644 --- a/package.json +++ b/package.json @@ -1412,9 +1412,10 @@ "test:contracts:plugins": "node scripts/run-vitest.mjs run --config test/vitest/vitest.contracts-plugin.config.ts --maxWorkers=1", "test:coverage": "node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts --coverage", "test:coverage:changed": "node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts --coverage --changed origin/main", - "test:docker:all": "pnpm test:docker:live-build && OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-models && OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-gateway && pnpm test:docker:openwebui && pnpm test:docker:onboard && pnpm test:docker:gateway-network && pnpm test:docker:mcp-channels && pnpm test:docker:qr && pnpm test:docker:doctor-switch && pnpm test:docker:plugins && pnpm test:docker:bundled-channel-deps && pnpm test:docker:cleanup", + "test:docker:all": "pnpm test:docker:live-build && OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-models && OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-gateway && pnpm test:docker:openwebui && pnpm test:docker:onboard && pnpm test:docker:gateway-network && pnpm test:docker:mcp-channels && pnpm test:docker:cron-mcp-cleanup && pnpm test:docker:qr && pnpm test:docker:doctor-switch && pnpm test:docker:plugins && pnpm test:docker:bundled-channel-deps && pnpm test:docker:cleanup", "test:docker:bundled-channel-deps": "bash scripts/e2e/bundled-channel-runtime-deps-docker.sh", "test:docker:cleanup": "bash scripts/test-cleanup-docker.sh", + "test:docker:cron-mcp-cleanup": "bash scripts/e2e/cron-mcp-cleanup-docker.sh", "test:docker:doctor-switch": "bash scripts/e2e/doctor-install-switch-docker.sh", "test:docker:gateway-network": "bash scripts/e2e/gateway-network-docker.sh", "test:docker:live-acp-bind": "bash scripts/test-live-acp-bind-docker.sh", diff --git a/scripts/e2e/cron-mcp-cleanup-docker-client.ts b/scripts/e2e/cron-mcp-cleanup-docker-client.ts new file mode 100644 index 00000000000..8c062d16972 --- /dev/null +++ b/scripts/e2e/cron-mcp-cleanup-docker-client.ts @@ -0,0 +1,148 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { promisify } from "node:util"; +import { assert, connectGateway, waitFor } from "./mcp-channels-harness.ts"; + +const execFileAsync = promisify(execFile); + +type CronJob = { id?: string }; +type CronRunResult = { ok?: boolean; enqueued?: boolean; runId?: string }; + +async function readProbePid(pidPath: string): Promise { + try { + const raw = (await fs.readFile(pidPath, "utf-8")).trim(); + const pid = Number.parseInt(raw, 10); + return Number.isInteger(pid) && pid > 0 ? pid : undefined; + } catch { + return undefined; + } +} + +async function describeProbePid(pid: number): Promise { + try { + const { stdout } = await execFileAsync("ps", ["-p", String(pid), "-o", "args="]); + const args = stdout.trim(); + return args.length > 0 ? args : undefined; + } catch { + return undefined; + } +} + +async function waitForProbePid(pidPath: string): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < 60_000) { + const pid = await readProbePid(pidPath); + if (pid) { + return pid; + } + await delay(100); + } + return undefined; +} + +async function waitForProbeExit(pid: number): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < 30_000) { + const args = await describeProbePid(pid); + if (!args || !args.includes("openclaw-cron-mcp-cleanup-probe")) { + return; + } + await delay(100); + } + const args = await describeProbePid(pid); + throw new Error(`cron MCP probe process still alive after run: pid=${pid} args=${args}`); +} + +async function main() { + const gatewayUrl = process.env.GW_URL?.trim(); + const gatewayToken = process.env.GW_TOKEN?.trim(); + const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || path.join(os.homedir(), ".openclaw"); + const pidPath = path.join(stateDir, "cron-mcp-cleanup", "probe.pid"); + assert(gatewayUrl, "missing GW_URL"); + assert(gatewayToken, "missing GW_TOKEN"); + + const gateway = await connectGateway({ url: gatewayUrl, token: gatewayToken }); + try { + const job = await gateway.request("cron.add", { + name: "cron mcp cleanup docker e2e", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payload: { + kind: "agentTurn", + message: "Use available context and then stop.", + timeoutSeconds: 12, + lightContext: true, + }, + delivery: { mode: "none" }, + }); + assert(job.id, `cron.add did not return an id: ${JSON.stringify(job)}`); + + const run = await gateway.request("cron.run", { + id: job.id, + mode: "force", + }); + assert( + run.ok === true && run.enqueued === true, + `cron.run was not enqueued: ${JSON.stringify(run)}`, + ); + + const started = await waitFor( + "cron started event", + () => + gateway.events.find( + (entry) => + entry.event === "cron" && + entry.payload.jobId === job.id && + entry.payload.action === "started", + )?.payload, + 60_000, + ); + assert(started, "missing cron started event"); + + const pid = await waitForProbePid(pidPath); + assert( + pid, + `cron MCP probe did not start; missing pid file at ${pidPath}; events=${JSON.stringify( + gateway.events.slice(-10), + )}`, + ); + const initialArgs = await describeProbePid(pid); + assert( + initialArgs?.includes("openclaw-cron-mcp-cleanup-probe"), + `cron MCP probe pid did not look like the test server: pid=${pid} args=${initialArgs}`, + ); + + const finished = await waitFor( + "cron finished event", + () => + gateway.events.find( + (entry) => + entry.event === "cron" && + entry.payload.jobId === job.id && + entry.payload.action === "finished", + )?.payload, + 90_000, + ); + assert(finished, "missing cron finished event"); + + await waitForProbeExit(pid); + process.stdout.write( + JSON.stringify({ + ok: true, + jobId: job.id, + runId: run.runId, + pid, + status: finished.status, + }) + "\n", + ); + } finally { + await gateway.close(); + } +} + +await main(); diff --git a/scripts/e2e/cron-mcp-cleanup-docker.sh b/scripts/e2e/cron-mcp-cleanup-docker.sh new file mode 100644 index 00000000000..b5e3f99dfde --- /dev/null +++ b/scripts/e2e/cron-mcp-cleanup-docker.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh" +IMAGE_NAME="${OPENCLAW_IMAGE:-openclaw-cron-mcp-cleanup-e2e}" +PORT="18789" +TOKEN="cron-mcp-e2e-$(date +%s)-$$" +CONTAINER_NAME="openclaw-cron-mcp-e2e-$$" +CLIENT_LOG="$(mktemp -t openclaw-cron-mcp-client-log.XXXXXX)" + +cleanup() { + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + rm -f "$CLIENT_LOG" +} +trap cleanup EXIT + +echo "Building Docker image..." +run_logged cron-mcp-cleanup-build docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" + +echo "Running in-container cron MCP cleanup smoke..." +set +e +docker run --rm \ + --name "$CONTAINER_NAME" \ + -e "OPENCLAW_GATEWAY_TOKEN=$TOKEN" \ + -e "OPENCLAW_SKIP_CHANNELS=1" \ + -e "OPENCLAW_SKIP_GMAIL_WATCHER=1" \ + -e "OPENCLAW_SKIP_CANVAS_HOST=1" \ + -e "OPENCLAW_STATE_DIR=/tmp/openclaw-state" \ + -e "OPENCLAW_CONFIG_PATH=/tmp/openclaw-state/openclaw.json" \ + -e "GW_URL=ws://127.0.0.1:$PORT" \ + -e "GW_TOKEN=$TOKEN" \ + -e "OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1" \ + "$IMAGE_NAME" \ + bash -lc "set -euo pipefail + entry=dist/index.mjs + [ -f \"\$entry\" ] || entry=dist/index.js + node --import tsx scripts/e2e/cron-mcp-cleanup-seed.ts >/tmp/cron-mcp-cleanup-seed.log + node \"\$entry\" gateway --port $PORT --bind loopback --allow-unconfigured >/tmp/cron-mcp-cleanup-gateway.log 2>&1 & + gateway_pid=\$! + cleanup_inner() { + kill \"\$gateway_pid\" >/dev/null 2>&1 || true + wait \"\$gateway_pid\" >/dev/null 2>&1 || true + } + dump_gateway_log_on_error() { + status=\$? + if [ \"\$status\" -ne 0 ]; then + tail -n 80 /tmp/cron-mcp-cleanup-gateway.log 2>/dev/null || true + cat /tmp/cron-mcp-cleanup-seed.log 2>/dev/null || true + fi + cleanup_inner + exit \"\$status\" + } + trap cleanup_inner EXIT + trap dump_gateway_log_on_error ERR + for _ in \$(seq 1 80); do + if node --input-type=module -e ' + import net from \"node:net\"; + const socket = net.createConnection({ host: \"127.0.0.1\", port: $PORT }); + const timeout = setTimeout(() => { + socket.destroy(); + process.exit(1); + }, 400); + socket.on(\"connect\", () => { + clearTimeout(timeout); + socket.end(); + process.exit(0); + }); + socket.on(\"error\", () => { + clearTimeout(timeout); + process.exit(1); + }); + ' >/dev/null 2>&1; then + break + fi + sleep 0.25 + done + node --import tsx scripts/e2e/cron-mcp-cleanup-docker-client.ts + " >"$CLIENT_LOG" 2>&1 +status=${PIPESTATUS[0]} +set -e + +if [ "$status" -ne 0 ]; then + echo "Docker cron MCP cleanup smoke failed" + cat "$CLIENT_LOG" + exit "$status" +fi + +echo "OK" diff --git a/scripts/e2e/cron-mcp-cleanup-seed.ts b/scripts/e2e/cron-mcp-cleanup-seed.ts new file mode 100644 index 00000000000..e6c4383cc2e --- /dev/null +++ b/scripts/e2e/cron-mcp-cleanup-seed.ts @@ -0,0 +1,134 @@ +import fs from "node:fs/promises"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; +import { + applyProviderConfigWithDefaultModelPreset, + type ModelDefinitionConfig, + type OpenClawConfig, +} from "../../src/plugin-sdk/provider-onboard.ts"; + +const require = createRequire(import.meta.url); + +const DOCKER_OPENAI_MODEL_REF = "openai/gpt-5.4"; +const DOCKER_OPENAI_MODEL: ModelDefinitionConfig = { + id: "gpt-5.4", + name: "gpt-5.4", + api: "openai-responses", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1_050_000, + maxTokens: 128_000, +}; + +async function writeProbeServer(params: { serverPath: string; pidPath: string; exitPath: string }) { + const sdkMcpServerPath = require.resolve("@modelcontextprotocol/sdk/server/mcp.js"); + const sdkStdioServerPath = require.resolve("@modelcontextprotocol/sdk/server/stdio.js"); + await fs.writeFile( + params.serverPath, + `#!/usr/bin/env node +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import { McpServer } from ${JSON.stringify(sdkMcpServerPath)}; +import { StdioServerTransport } from ${JSON.stringify(sdkStdioServerPath)}; + +process.title = "openclaw-cron-mcp-cleanup-probe"; +await fsp.mkdir(${JSON.stringify(path.dirname(params.pidPath))}, { recursive: true }); +await fsp.writeFile(${JSON.stringify(params.pidPath)}, String(process.pid), "utf8"); +process.once("exit", () => { + try { + fs.writeFileSync(${JSON.stringify(params.exitPath)}, "exited", "utf8"); + } catch {} +}); +for (const signal of ["SIGINT", "SIGTERM"]) { + process.once(signal, () => { + process.exit(0); + }); +} + +setInterval(() => {}, 1000); + +const server = new McpServer({ name: "cron-mcp-cleanup-probe", version: "1.0.0" }); +server.tool("cleanup_probe", "Cron MCP cleanup probe", async () => ({ + content: [{ type: "text", text: "cron-mcp-cleanup-ok" }], +})); + +await server.connect(new StdioServerTransport()); +`, + { encoding: "utf-8", mode: 0o755 }, + ); +} + +async function main() { + const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || path.join(os.homedir(), ".openclaw"); + const configPath = + process.env.OPENCLAW_CONFIG_PATH?.trim() || path.join(stateDir, "openclaw.json"); + const probeDir = path.join(stateDir, "cron-mcp-cleanup"); + const serverPath = path.join(probeDir, "probe-server.mjs"); + const pidPath = path.join(probeDir, "probe.pid"); + const exitPath = path.join(probeDir, "probe.exit"); + + await fs.mkdir(probeDir, { recursive: true }); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.rm(pidPath, { force: true }); + await fs.rm(exitPath, { force: true }); + await writeProbeServer({ serverPath, pidPath, exitPath }); + + const seededConfig = applyProviderConfigWithDefaultModelPreset( + { + gateway: { + controlUi: { + allowInsecureAuth: true, + enabled: false, + }, + }, + cron: { + enabled: false, + }, + mcp: { + servers: { + cronCleanupProbe: { + command: "node", + args: [serverPath], + cwd: probeDir, + }, + }, + }, + } satisfies OpenClawConfig, + { + providerId: "openai", + api: "openai-responses", + baseUrl: "http://127.0.0.1:9/v1", + defaultModel: DOCKER_OPENAI_MODEL, + defaultModelId: DOCKER_OPENAI_MODEL.id, + aliases: [{ modelRef: DOCKER_OPENAI_MODEL_REF, alias: "GPT" }], + primaryModelRef: DOCKER_OPENAI_MODEL_REF, + }, + ); + const openAiProvider = seededConfig.models?.providers?.openai; + if (!openAiProvider) { + throw new Error("failed to seed OpenAI provider config"); + } + openAiProvider.apiKey = "sk-docker-cron-mcp-cleanup-test"; + + await fs.writeFile(configPath, `${JSON.stringify(seededConfig, null, 2)}\n`, "utf-8"); + + process.stdout.write( + JSON.stringify({ + ok: true, + stateDir, + configPath, + serverPath, + pidPath, + exitPath, + }) + "\n", + ); +} + +await main(); diff --git a/scripts/e2e/mcp-channels-harness.ts b/scripts/e2e/mcp-channels-harness.ts index 3fc0bbe0b5c..c051d59d537 100644 --- a/scripts/e2e/mcp-channels-harness.ts +++ b/scripts/e2e/mcp-channels-harness.ts @@ -180,7 +180,7 @@ async function connectGatewayOnce(params: { } pending.delete(typed.id); if (typed.ok === true) { - match.resolve(typed.result); + match.resolve(typed.payload ?? typed.result); return; } match.reject(