diff --git a/config/knip.config.ts b/config/knip.config.ts index 75a45b5fa97f..3abea054cd43 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -44,8 +44,6 @@ const repositoryScriptEntries = [ "scripts/e2e/lib/openai-chat-tools/client.mjs!", "scripts/e2e/lib/openai-chat-tools/write-config.mjs!", "scripts/e2e/lib/package-git-fixture.mjs!", - "scripts/e2e/lib/parallels-package/build-info-commit.mjs!", - "scripts/e2e/lib/parallels-package/log-progress-extract.mjs!", "scripts/e2e/lib/plugin-lifecycle-matrix/measure.mjs!", "scripts/e2e/lib/plugin-update/registry-server.mjs!", "scripts/e2e/lib/plugins/npm-registry-server.mjs!", diff --git a/scripts/e2e/channel-plugin-trust-docker.sh b/scripts/e2e/channel-plugin-trust-docker.sh deleted file mode 100644 index e231fe25b928..000000000000 --- a/scripts/e2e/channel-plugin-trust-docker.sh +++ /dev/null @@ -1,324 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Definition: -# Docker/package E2E proof for local channel plugin trust gating. The host -# mode builds or reuses the functional Docker image, then runs the container -# mode against the installed OpenClaw package. -# -# Parameters: -# --container: run the in-container scenario. Host mode is the default. -# OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_IMAGE: override the Docker image name. -# OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_SKIP_BUILD=1: reuse/pull the image. -# -# Outputs: -# stdout logs each case and prints "Channel plugin trust Docker E2E passed." -# Exit 0 means both representative package-environment cases passed. -# Exit non-zero means the package build, Docker run, or trust assertion failed. - -usage() { - cat <<'EOF' -Usage: - bash scripts/e2e/channel-plugin-trust-docker.sh [--container] - -Description: - Proves the packaged OpenClaw CLI enforces local channel plugin trust for - plugins.load.paths entries in a clean Docker/package environment. - -Options: - --container Run the in-container scenario. Used by the host wrapper. - -h, --help Show this help. - -Environment: - OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_IMAGE Override Docker image name. - OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_SKIP_BUILD Reuse/pull image instead of building. - OPENCLAW_TEST_STATE_SCRIPT_B64 Required in --container mode. - -Outputs: - Prints case progress and PASS lines to stdout. Exits non-zero on assertion - failure and leaves the failing command output in the container log. - -Examples: - bash scripts/e2e/channel-plugin-trust-docker.sh - OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_SKIP_BUILD=1 bash scripts/e2e/channel-plugin-trust-docker.sh -EOF -} - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" - -run_openclaw() { - if command -v openclaw >/dev/null 2>&1; then - openclaw "$@" - return - fi - if [ -f /app/openclaw.mjs ]; then - node /app/openclaw.mjs "$@" - return - fi - echo "openclaw CLI not found in Docker image" >&2 - exit 1 -} - -write_load_paths_fixture() { - local plugin_dir="${1:?missing plugin dir}" - local origin="${2:?missing origin}" - local plugin_id="e2e-load-paths-shadow" - local channel_id="e2e-load-paths" - mkdir -p "$plugin_dir" - - cat >"$plugin_dir/package.json" <"$plugin_dir/openclaw.plugin.json" <"$plugin_dir/index.cjs" <"; -function writeMarker(target, payload) { - if (!target) return; - fs.mkdirSync(path.dirname(target), { recursive: true }); - fs.writeFileSync(target, payload, "utf8"); -} -writeMarker(importMarker, "imported|origin=$origin|canary=" + canary + "\\n"); -module.exports = { - id: "$plugin_id", - register(api) { - writeMarker(registerMarker, "registered|origin=$origin|canary=" + canary + "\\n"); - api.registerChannel({ - plugin: { - id: "$channel_id", - meta: { - id: "$channel_id", - label: "E2E Load Paths", - selectionLabel: "E2E Load Paths", - docsPath: "/channels/$channel_id", - blurb: "Docker E2E local trust fixture.", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => [], - resolveAccount: () => ({ accountId: "default" }), - }, - outbound: { deliveryMode: "direct" }, - }, - }); - }, -}; -EOF - - cat >"$plugin_dir/setup-entry.cjs" <"; -function writeMarker(target, payload) { - if (!target) return; - fs.mkdirSync(path.dirname(target), { recursive: true }); - fs.writeFileSync(target, payload, "utf8"); -} -writeMarker(importMarker, "setup-imported|origin=$origin|canary=" + canary + "\\n"); -module.exports = { - plugin: { - id: "$channel_id", - meta: { - id: "$channel_id", - label: "E2E Load Paths setup", - selectionLabel: "E2E Load Paths setup", - docsPath: "/channels/$channel_id", - blurb: "Docker E2E local trust setup fixture.", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => [], - resolveAccount: () => ({ accountId: "default" }), - }, - outbound: { deliveryMode: "direct" }, - setup: { - validateInput: ({ input }) => { - writeMarker( - registerMarker, - "setup-registered|origin=$origin|canary=" + canary + "|token=" + (input?.token ?? "") + "\\n", - ); - return null; - }, - applyAccountConfig: ({ cfg }) => cfg, - }, - }, -}; -EOF -} - -write_case_config() { - local plugin_dir="${1:?missing plugin dir}" - local trusted="${2:?missing trusted flag}" - local plugin_id="e2e-load-paths-shadow" - mkdir -p "$(dirname "$OPENCLAW_CONFIG_PATH")" - if [ "$trusted" = "1" ]; then - cat >"$OPENCLAW_CONFIG_PATH" <"$OPENCLAW_CONFIG_PATH" <"$stdout_file" 2>"$stderr_file" - local status=$? - set -e - - if [ "$trusted" = "1" ] && [ "$status" -ne 0 ]; then - echo "Expected trusted case to succeed; exit=$status" >&2 - cat "$stderr_file" >&2 || true - exit 1 - fi - - if [ "$trusted" = "1" ]; then - for marker in setup-import setup-register; do - local marker_path="$marker_dir/$marker.marker" - if [ ! -f "$marker_path" ]; then - echo "Expected $marker marker for trusted case" >&2 - cat "$stderr_file" >&2 || true - exit 1 - fi - if ! grep -qF "canary=$canary" "$marker_path"; then - echo "$marker marker did not include canary $canary" >&2 - cat "$marker_path" >&2 || true - exit 1 - fi - done - echo "PASS: $case_id trusted load-paths setup entry executed" - else - for marker in setup-import setup-register import register; do - if [ -e "$marker_dir/$marker.marker" ]; then - echo "Expected $marker marker to be absent for untrusted case" >&2 - cat "$marker_dir/$marker.marker" >&2 || true - exit 1 - fi - done - echo "PASS: $case_id untrusted load-paths setup entry blocked" - fi -} - -run_container() { - source scripts/lib/openclaw-e2e-instance.sh - openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}" - export OPENCLAW_WORKSPACE_DIR="$HOME/.openclaw/workspace" - - run_openclaw --version - run_case untrusted-load-paths 0 - run_case trusted-load-paths 1 - echo "Channel plugin trust Docker E2E passed." -} - -run_host() { - source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh" - local image_name - image_name="$( - docker_e2e_resolve_image \ - "openclaw-channel-plugin-trust-e2e:local" \ - OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_IMAGE - )" - local skip_build="${OPENCLAW_CHANNEL_PLUGIN_TRUST_E2E_SKIP_BUILD:-0}" - docker_e2e_build_or_reuse "$image_name" channel-plugin-trust "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "" "$skip_build" - - local state_script_b64 - state_script_b64="$(docker_e2e_test_state_shell_b64 channel-plugin-trust minimal)" - echo "Running channel plugin trust Docker E2E..." - docker_e2e_run_logged_print_with_harness \ - channel-plugin-trust \ - -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ - -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$state_script_b64" \ - "$image_name" \ - bash scripts/e2e/channel-plugin-trust-docker.sh --container -} - -case "${1:-}" in - -h | --help) - usage - ;; - --container) - run_container - ;; - "") - run_host - ;; - *) - echo "Unknown argument: $1" >&2 - echo >&2 - usage >&2 - exit 1 - ;; -esac diff --git a/scripts/e2e/lib/parallels-macos-common.sh b/scripts/e2e/lib/parallels-macos-common.sh deleted file mode 100644 index 8fc98c1d4e1f..000000000000 --- a/scripts/e2e/lib/parallels-macos-common.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env bash - -parallels_macos_resolve_desktop_user() { - local vm_name="$1" - local user - user="$(prlctl exec "$vm_name" /usr/bin/stat -f '%Su' /dev/console 2>/dev/null | tr -d '\r' | tail -n 1 || true)" - if [[ "$user" =~ ^[A-Za-z0-9._-]+$ && "$user" != "root" && "$user" != "loginwindow" ]]; then - printf '%s\n' "$user" - return 0 - fi - prlctl exec "$vm_name" /usr/bin/dscl . -list /Users NFSHomeDirectory 2>/dev/null \ - | tr -d '\r' \ - | awk '$2 ~ /^\/Users\// && $1 !~ /^_/ && $1 != "Shared" && $1 != ".localized" { print $1; exit }' -} - -parallels_macos_resolve_desktop_home() { - local vm_name="$1" - local user="$2" - local home - home="$( - prlctl exec "$vm_name" /usr/bin/dscl . -read "/Users/$user" NFSHomeDirectory 2>/dev/null \ - | tr -d '\r' \ - | awk '/NFSHomeDirectory:/ { print $2; exit }' - )" - if [[ -n "$home" ]]; then - printf '%s\n' "$home" - else - printf '/Users/%s\n' "$user" - fi -} diff --git a/scripts/e2e/lib/parallels-package-common.sh b/scripts/e2e/lib/parallels-package-common.sh deleted file mode 100644 index cb912e568c57..000000000000 --- a/scripts/e2e/lib/parallels-package-common.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env bash - -parallels_package_acquire_build_lock() { - local lock_dir="$1" - local owner_pid="" - while ! mkdir "$lock_dir" 2>/dev/null; do - if [[ -f "$lock_dir/pid" ]]; then - owner_pid="$(cat "$lock_dir/pid" 2>/dev/null || true)" - if [[ -n "$owner_pid" ]] && ! kill -0 "$owner_pid" >/dev/null 2>&1; then - printf 'warn: Removing stale Parallels build lock\n' >&2 - rm -rf "$lock_dir" - continue - fi - fi - sleep 1 - done - printf '%s\n' "$$" >"$lock_dir/pid" -} - -parallels_package_release_build_lock() { - local lock_dir="$1" - if [[ -d "$lock_dir" ]]; then - rm -rf "$lock_dir" - fi -} diff --git a/scripts/e2e/lib/parallels-package/build-info-commit.mjs b/scripts/e2e/lib/parallels-package/build-info-commit.mjs deleted file mode 100644 index 603b1e0f65b6..000000000000 --- a/scripts/e2e/lib/parallels-package/build-info-commit.mjs +++ /dev/null @@ -1,10 +0,0 @@ -// Validates build-info commit metadata for Parallels package E2E scenarios. -import fs from "node:fs"; - -const path = "dist/build-info.json"; -if (!fs.existsSync(path)) { - console.log(""); -} else { - const buildInfo = JSON.parse(fs.readFileSync(path, "utf8")); - console.log(buildInfo.commit ?? ""); -} diff --git a/scripts/e2e/lib/parallels-package/log-progress-extract.mjs b/scripts/e2e/lib/parallels-package/log-progress-extract.mjs deleted file mode 100644 index a2523a308840..000000000000 --- a/scripts/e2e/lib/parallels-package/log-progress-extract.mjs +++ /dev/null @@ -1,22 +0,0 @@ -// Extracts progress markers from Parallels package E2E logs. -import fs from "node:fs"; -import { readTextFileTail } from "../text-file-utils.mjs"; - -const LOG_PROGRESS_TAIL_BYTES = 256 * 1024; - -const [logPath] = process.argv.slice(2); -if (!logPath || !fs.existsSync(logPath)) { - console.log(""); - process.exit(0); -} - -const text = readTextFileTail(logPath, LOG_PROGRESS_TAIL_BYTES); -const lines = text - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); -const reversed = lines.toReversed(); - -const progress = reversed.find((line) => line.startsWith("==> ")); -const warning = reversed.find((line) => line.startsWith("warn:") || line.startsWith("error:")); -console.log(progress?.slice(4).trim() ?? warning ?? lines.at(-1)?.slice(0, 240) ?? ""); diff --git a/scripts/e2e/lib/pond-gateway-rpc.d.mts b/scripts/e2e/lib/pond-gateway-rpc.d.mts deleted file mode 100644 index fd437f40b2a6..000000000000 --- a/scripts/e2e/lib/pond-gateway-rpc.d.mts +++ /dev/null @@ -1,23 +0,0 @@ -export type PondGatewayRpcOptions = { - url: string; - token: string; - scopes: string[]; - openTimeoutMs?: number; - webSocketFactory?: (target: string) => unknown; -}; - -export type PondGatewayRpcRequestOptions = { - expectFinal?: boolean; - timeoutMs?: number; -}; - -export declare class PondGatewayRpc { - constructor(options: PondGatewayRpcOptions); - connect(): Promise; - request( - method: string, - params?: unknown, - options?: PondGatewayRpcRequestOptions, - ): Promise; - close(): void; -} diff --git a/scripts/e2e/lib/pond-gateway-rpc.mjs b/scripts/e2e/lib/pond-gateway-rpc.mjs deleted file mode 100644 index bc28554d0a5e..000000000000 --- a/scripts/e2e/lib/pond-gateway-rpc.mjs +++ /dev/null @@ -1,158 +0,0 @@ -// Gateway RPC client used by the Pond E2E verifier. -import { WebSocket } from "ws"; -import { waitForWebSocketOpen } from "./websocket-open.mjs"; - -function asError(value) { - return value instanceof Error ? value : new Error(String(value)); -} - -function formatCloseReason(code, reason) { - const text = reason instanceof Uint8Array ? Buffer.from(reason).toString("utf8") : String(reason); - return text ? ` (${code}): ${text}` : ` (${code})`; -} - -export class PondGatewayRpc { - constructor({ - url, - token, - scopes, - openTimeoutMs = 15_000, - webSocketFactory = (target) => new WebSocket(target), - }) { - this.url = url; - this.token = token; - this.scopes = scopes; - this.openTimeoutMs = openTimeoutMs; - this.webSocketFactory = webSocketFactory; - this.pending = new Map(); - this.nextId = 1; - } - - async connect() { - this.ws = this.webSocketFactory(this.url); - this.ws.on("message", (data) => this.onMessage(data)); - // These listeners outlive the open wait so post-handshake failures reject RPCs - // instead of surfacing as uncaught EventEmitter errors. - this.ws.on("error", (error) => this.rejectPending(asError(error))); - this.ws.on("close", (code, reason) => { - this.rejectPending(new Error(`Gateway socket closed${formatCloseReason(code, reason)}`)); - }); - - try { - await waitForWebSocketOpen( - this.ws, - this.openTimeoutMs, - `Gateway connect timeout: ${this.url}`, - ); - await this.request("connect", { - minProtocol: 1, - maxProtocol: 99, - client: { - id: "gateway-client", - displayName: "Pond proof verifier", - version: "0.0.0", - platform: process.platform, - mode: "backend", - }, - auth: { token: this.token }, - role: "operator", - scopes: this.scopes, - }); - } catch (error) { - // Callers only receive the client after connect succeeds, so failed setup - // must close its socket here or the E2E process can remain alive. - this.close(); - throw error; - } - } - - rejectPending(error) { - this.terminalError ??= error; - for (const pending of this.pending.values()) { - clearTimeout(pending.timer); - pending.reject(this.terminalError); - } - this.pending.clear(); - } - - onMessage(data) { - let frame; - try { - frame = JSON.parse(String(data)); - } catch { - return; - } - if (frame?.type !== "res" || typeof frame.id !== "string") { - return; - } - const pending = this.pending.get(frame.id); - if (!pending) { - return; - } - if (pending.expectFinal && frame.payload?.status === "accepted") { - return; - } - this.pending.delete(frame.id); - clearTimeout(pending.timer); - if (frame.ok) { - pending.resolve(frame.payload); - return; - } - pending.reject(new Error(frame.error?.message ?? `Gateway RPC failed: ${pending.method}`)); - } - - request(method, params = {}, options = {}) { - if (this.terminalError) { - return Promise.reject(asError(this.terminalError)); - } - if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { - return Promise.reject(new Error(`Gateway socket is not open for RPC: ${method}`)); - } - - const id = `pond-proof-${this.nextId}`; - this.nextId += 1; - const timeoutMs = options.timeoutMs ?? 30_000; - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.pending.delete(id); - reject(new Error(`Gateway RPC timeout: ${method}`)); - }, timeoutMs); - this.pending.set(id, { - method, - expectFinal: options.expectFinal === true, - resolve, - reject, - timer, - }); - try { - this.ws.send(JSON.stringify({ type: "req", id, method, params }), (error) => { - if (!error) { - return; - } - const pending = this.pending.get(id); - if (!pending) { - return; - } - this.pending.delete(id); - clearTimeout(pending.timer); - pending.reject(asError(error)); - }); - } catch (error) { - this.pending.delete(id); - clearTimeout(timer); - reject(asError(error)); - } - }); - } - - close() { - this.rejectPending(new Error("Gateway RPC client closed")); - if (this.ws?.readyState === WebSocket.CONNECTING) { - this.ws.terminate(); - return; - } - if (this.ws?.readyState === WebSocket.OPEN) { - this.ws.close(); - } - } -} diff --git a/scripts/e2e/node-plugin-tools-pond.mjs b/scripts/e2e/node-plugin-tools-pond.mjs deleted file mode 100644 index b7a733b0e574..000000000000 --- a/scripts/e2e/node-plugin-tools-pond.mjs +++ /dev/null @@ -1,1611 +0,0 @@ -#!/usr/bin/env node -import { spawn } from "node:child_process"; -import crypto from "node:crypto"; -import fs from "node:fs/promises"; -import { createRequire } from "node:module"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; -import process from "node:process"; -import { PondGatewayRpc } from "./lib/pond-gateway-rpc.mjs"; - -const DEFAULT_PORT = 18789; -const SESSION_KEY = "agent:main:main"; -const PLUGIN_ID = "pond-node-tools"; -const MCP_PLUGIN_ID = "node-mcp"; -const MCP_SERVER_NAME = "pond"; -const MCP_TOOL_NAME = "pond_echo"; -const MCP_SLOW_TOOL_NAME = "pond_slow"; -const FILESYSTEM_MCP_SERVER_NAME = "filesystem"; -const FILESYSTEM_MCP_TOOL_NAME = "filesystem_read_text_file"; -const FILESYSTEM_MCP_PACKAGE = "@modelcontextprotocol/server-filesystem@2026.7.4"; -const SHARED_TOOL_NAME = "pond_shared_probe"; -const SKILL_NAME = "pond-node-skill"; -const DEFAULT_LIVE_MODEL = "anthropic/claude-sonnet-4-6"; -const DEFAULT_HOT_PLUG_LIVE_MODEL = "openai/gpt-5.4"; -const LIVE_TURN_TIMEOUT_MS = 120_000; -const require = createRequire(import.meta.url); -let verboseOutput = false; - -function parseArgs(argv) { - const args = { _: [] }; - for (let index = 0; index < argv.length; index += 1) { - const value = argv[index]; - if (!value.startsWith("--")) { - args._.push(value); - continue; - } - const key = value.slice(2); - const next = argv[index + 1]; - if (!next || next.startsWith("--")) { - args[key] = true; - continue; - } - args[key] = next; - index += 1; - } - return args; -} - -function repoRoot() { - return path.resolve(import.meta.dirname, "..", ".."); -} - -function now() { - return Date.now(); -} - -function proofToken() { - return `pond-proof-${crypto.randomBytes(12).toString("hex")}`; -} - -async function availableLoopbackPort() { - const server = net.createServer(); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", resolve); - }); - const address = server.address(); - const port = typeof address === "object" && address ? address.port : 0; - await new Promise((resolve, reject) => { - server.close((error) => { - if (error) { - reject(error instanceof Error ? error : new Error(String(error))); - return; - } - resolve(); - }); - }); - if (!port) { - throw new Error("failed to allocate pond gateway port"); - } - return port; -} - -function logStep(message) { - console.log(`[pond-proof] ${message}`); -} - -async function writeJson(filePath, value, options = {}) { - await fs.mkdir(path.dirname(filePath), { recursive: true }); - const data = `${JSON.stringify(value, null, 2)}\n`; - if (typeof options.mode === "number") { - await fs.writeFile(filePath, data, { encoding: "utf8", mode: options.mode, flag: "w" }); - await fs.chmod(filePath, options.mode); - return; - } - await fs.writeFile(filePath, data, "utf8"); -} - -async function writeProofPlugin(rootDir, nodeLabel, options = {}) { - const pluginDir = path.join(rootDir, "plugin"); - const pluginPath = path.join(pluginDir, "pond-node-tools.mjs"); - await fs.mkdir(pluginDir, { recursive: true }); - await writeJson(path.join(pluginDir, "openclaw.plugin.json"), { - id: PLUGIN_ID, - name: "Pond Node Tools", - description: "Node-hosted plugin tool proof", - activation: { onStartup: true }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }); - await writeJson(path.join(pluginDir, "package.json"), { - name: PLUGIN_ID, - version: "0.0.0", - type: "module", - openclaw: { extensions: ["./pond-node-tools.mjs"] }, - }); - const sharedToolRegistration = options.sharedTool - ? ` - api.registerNodeHostCommand({ - command: "pond.sharedProbe", - agentTool: { - name: ${JSON.stringify(SHARED_TOOL_NAME)}, - description: "Return the node label for collision-disambiguation proof.", - parameters: { - type: "object", - properties: {}, - additionalProperties: false - }, - defaultPlatforms: ["linux", "macos"] - }, - handle: async () => JSON.stringify(${JSON.stringify(`${nodeLabel}-shared-ok`)}) - });` - : ""; - const source = ` -import os from "node:os"; - -const nodeLabel = process.env.OPENCLAW_POND_NODE_LABEL || ${JSON.stringify(nodeLabel)}; - -function readParams(paramsJSON) { - if (!paramsJSON) return {}; - try { - const parsed = JSON.parse(paramsJSON); - return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; - } catch { - return {}; - } -} - -export default { - id: ${JSON.stringify(PLUGIN_ID)}, - name: "Pond Node Tools", - description: "Node-hosted plugin tool proof", - register(api) { - api.registerNodeHostCommand({ - command: "pond.echo", - agentTool: { - name: "pond_echo", - description: "Echo proof payload from the connected node host.", - parameters: { - type: "object", - properties: { - message: { type: "string" } - }, - required: ["message"], - additionalProperties: false - }, - defaultPlatforms: ["linux", "macos"], - mcp: { server: "pond-proof", tool: "echo" } - }, - handle: async (paramsJSON) => - JSON.stringify({ - ok: true, - nodeLabel, - hostname: os.hostname(), - params: readParams(paramsJSON) - }) - });${sharedToolRegistration} - } -}; -`.trimStart(); - await fs.writeFile(pluginPath, source, "utf8"); - return pluginDir; -} - -async function writeProofMcpServer(rootDir) { - const serverPath = path.join(rootDir, "mcp", "pond-echo.mjs"); - const callLogPath = path.join(rootDir, "mcp", "calls.jsonl"); - const slowCallLogPath = path.join(rootDir, "mcp", "slow-calls.jsonl"); - const sdkMcpServerPath = require.resolve("@modelcontextprotocol/sdk/server/mcp.js"); - const sdkStdioServerPath = require.resolve("@modelcontextprotocol/sdk/server/stdio.js"); - const zodPath = require.resolve("zod"); - await fs.mkdir(path.dirname(serverPath), { recursive: true }); - const source = `#!/usr/bin/env node -import { appendFile } from "node:fs/promises"; -import { McpServer } from ${JSON.stringify(sdkMcpServerPath)}; -import { StdioServerTransport } from ${JSON.stringify(sdkStdioServerPath)}; -import { z } from ${JSON.stringify(zodPath)}; - -const callLogPath = ${JSON.stringify(callLogPath)}; -const slowCallLogPath = ${JSON.stringify(slowCallLogPath)}; -const server = new McpServer({ name: "pond-echo-fixture", version: "1.0.0" }); - -server.tool( - ${JSON.stringify(MCP_TOOL_NAME)}, - "Echo a pond proof payload.", - { text: z.string() }, - async ({ text }) => { - await appendFile(callLogPath, JSON.stringify({ text }) + "\\n", "utf8"); - return { - content: [{ type: "text", text: JSON.stringify({ text }) }], - }; - }, -); - -server.tool( - ${JSON.stringify(MCP_SLOW_TOOL_NAME)}, - "Return a pond proof payload after a deliberate ten-second delay.", - { text: z.string() }, - async ({ text }) => { - await appendFile(slowCallLogPath, JSON.stringify({ phase: "started", text }) + "\\n", "utf8"); - await new Promise((resolve) => setTimeout(resolve, 10_000)); - await appendFile(slowCallLogPath, JSON.stringify({ phase: "finished", text }) + "\\n", "utf8"); - return { - content: [{ type: "text", text: JSON.stringify({ text }) }], - }; - }, -); - -await server.connect(new StdioServerTransport()); -`; - await fs.writeFile(serverPath, source, { encoding: "utf8", mode: 0o755 }); - return { callLogPath, serverPath, slowCallLogPath }; -} - -async function writeProofSkill(stateDir, proof) { - const skillPath = path.join(stateDir, "skills", SKILL_NAME, "SKILL.md"); - const content = `---\nname: ${SKILL_NAME}\ndescription: Pond node-hosted skill proof\n---\n\n# Pond node skill\n\nWhen asked for the pond marker, reply with exactly ${proof}.\n`; - await fs.mkdir(path.dirname(skillPath), { recursive: true }); - await fs.writeFile(skillPath, content, "utf8"); - return skillPath; -} - -async function writeRemoteExecProofSkill(stateDir, proofFilePath, skillName) { - const skillPath = path.join(stateDir, "skills", skillName, "SKILL.md"); - const content = `---\nname: ${skillName}\ndescription: Read the pond node-local proof file\n---\n\n# Pond node skill\n\nWhen asked for the pond node-local marker, use the exec tool with host=node to run:\n\n\`\`\`sh\ncat ${JSON.stringify(proofFilePath)}\n\`\`\`\n\nReply with exactly the command output. Never infer or repeat a marker from this skill body.\n`; - await fs.mkdir(path.dirname(skillPath), { recursive: true }); - await fs.writeFile(skillPath, content, "utf8"); - return skillPath; -} - -async function prepareRoleState(baseDir, role, token, nodeLabel, options = {}) { - const rootDir = path.resolve(baseDir, role); - const stateDir = path.join(rootDir, "state"); - const configPath = path.join(rootDir, "openclaw.json"); - await fs.mkdir(rootDir, { recursive: true, mode: 0o700 }); - await fs.chmod(rootDir, 0o700); - const pluginPath = await writeProofPlugin(rootDir, nodeLabel, { - sharedTool: options.sharedTool === true, - }); - const mcpFixture = options.nodeSurfaces ? await writeProofMcpServer(rootDir) : undefined; - const mcpServerPath = mcpFixture?.serverPath; - const skillPath = options.nodeSurfaces - ? await writeProofSkill(stateDir, options.skillProofToken) - : undefined; - await writeJson( - configPath, - { - gateway: { - mode: "local", - bind: "lan", - auth: { mode: "token", token }, - nodes: { - allowCommands: [ - "pond.echo", - ...(options.sharedTool ? ["pond.sharedProbe"] : []), - "system.run", - ], - }, - }, - tools: { exec: { host: "node", security: "full", ask: "off" } }, - ...(options.liveModel - ? { - agents: { - defaults: { - model: { primary: options.liveModel, fallbacks: [] }, - models: { [options.liveModel]: { params: { fastMode: true } } }, - }, - }, - } - : {}), - plugins: { - load: { paths: [pluginPath] }, - entries: { [PLUGIN_ID]: { enabled: true } }, - }, - ...(mcpServerPath - ? { - nodeHost: { - mcp: { - servers: { - [MCP_SERVER_NAME]: { - command: process.execPath, - args: [mcpServerPath], - transport: "stdio", - // Keep the baseline surface singular; the hot-plug scenario owns the slow tool. - toolFilter: { include: [MCP_TOOL_NAME] }, - }, - }, - }, - skills: { enabled: true }, - }, - } - : {}), - }, - { mode: 0o600 }, - ); - await writeJson(path.join(stateDir, "agents", "main", "sessions", "sessions.json"), { - [SESSION_KEY]: { - sessionId: "pond-proof-main", - updatedAt: now(), - modelProvider: options.liveModel ? "anthropic" : "openai", - model: options.liveModel ? "claude-sonnet-4-6" : "gpt-5.5", - }, - }); - await writeJson( - path.join(stateDir, "exec-approvals.json"), - { - version: 1, - defaults: { security: "full", ask: "off" }, - agents: {}, - }, - { mode: 0o600 }, - ); - return { - rootDir, - stateDir, - configPath, - pluginPath, - mcpServerPath, - mcpCallLogPath: mcpFixture?.callLogPath, - mcpSlowCallLogPath: mcpFixture?.slowCallLogPath, - skillPath, - }; -} - -async function readJson(filePath) { - return JSON.parse(await fs.readFile(filePath, "utf8")); -} - -async function enableHotPlugNodeSurfaces(state, { mcpProofToken, skillProofToken, skillName }) { - const mcpProofDir = path.join(state.rootDir, "mcp-proof"); - const mcpProofFilePath = path.join(mcpProofDir, "proof.txt"); - const skillProofDir = path.join(state.rootDir, "skill-proof"); - const skillProofFilePath = path.join(skillProofDir, "proof.txt"); - await fs.mkdir(mcpProofDir, { recursive: true }); - await fs.mkdir(skillProofDir, { recursive: true }); - await fs.writeFile(mcpProofFilePath, `${mcpProofToken}\n`, "utf8"); - await fs.writeFile(skillProofFilePath, `${skillProofToken}\n`, "utf8"); - const mcpFixture = await writeProofMcpServer(state.rootDir); - const skillPath = await writeRemoteExecProofSkill(state.stateDir, skillProofFilePath, skillName); - const config = await readJson(state.configPath); - const filesystemCommand = process.platform === "win32" ? "cmd" : "npx"; - const filesystemArgs = [ - ...(process.platform === "win32" ? ["/c", "npx"] : []), - "-y", - FILESYSTEM_MCP_PACKAGE, - mcpProofDir, - ]; - config.nodeHost = { - mcp: { - servers: { - [FILESYSTEM_MCP_SERVER_NAME]: { - command: filesystemCommand, - args: filesystemArgs, - transport: "stdio", - toolFilter: { include: ["read_text_file"] }, - }, - [MCP_SERVER_NAME]: { - command: process.execPath, - args: [mcpFixture.serverPath], - transport: "stdio", - toolFilter: { include: [MCP_SLOW_TOOL_NAME] }, - }, - }, - }, - skills: { enabled: true }, - }; - await writeJson(state.configPath, config, { mode: 0o600 }); - return { - ...mcpFixture, - mcpProofDir, - mcpProofFilePath, - skillProofFilePath, - skillPath, - }; -} - -async function disableHotPlugNodeSurfaces(state, skillName = SKILL_NAME) { - const config = await readJson(state.configPath); - delete config.nodeHost; - await writeJson(state.configPath, config, { mode: 0o600 }); - await fs.rm(path.join(state.stateDir, "skills", skillName), { - force: true, - recursive: true, - }); -} - -function childEnv(state, token, nodeLabel) { - return { - ...process.env, - ...(process.env.OPENAI_API_KEY - ? {} - : process.env.OPENCLAW_LIVE_OPENAI_KEY - ? { OPENAI_API_KEY: process.env.OPENCLAW_LIVE_OPENAI_KEY } - : {}), - OPENCLAW_CONFIG_PATH: state.configPath, - OPENCLAW_STATE_DIR: state.stateDir, - OPENCLAW_GATEWAY_TOKEN: token, - OPENCLAW_POND_NODE_LABEL: nodeLabel, - }; -} - -function spawnOpenClaw(args, options) { - const cliArgs = options.built ? ["openclaw.mjs", ...args] : ["scripts/run-node.mjs", ...args]; - const child = spawn("node", cliArgs, { - cwd: repoRoot(), - env: options.env, - stdio: options.stdio ?? "inherit", - }); - child.on("exit", (code, signal) => { - if (options.onExit) { - options.onExit(code, signal); - } - }); - return child; -} - -async function runCommand(command, args, options = {}) { - const child = spawn(command, args, { - cwd: options.cwd ?? repoRoot(), - env: options.env ?? process.env, - stdio: options.stdio ?? "inherit", - }); - await waitForChild(child); -} - -function waitForChild(child) { - return new Promise((resolve, reject) => { - child.once("exit", (code, signal) => { - if (code === 0 || signal) { - resolve({ code, signal }); - return; - } - reject(new Error(`child exited with code ${code}`)); - }); - }); -} - -async function runForegroundChild(child) { - const forward = (signal) => { - if (child.exitCode === null) { - child.kill(signal); - } - }; - process.once("SIGTERM", forward); - process.once("SIGINT", forward); - try { - await waitForChild(child); - } finally { - process.off("SIGTERM", forward); - process.off("SIGINT", forward); - } -} - -function terminate(child) { - return new Promise((resolve) => { - if (!child || child.exitCode !== null) { - resolve(); - return; - } - const timer = setTimeout(() => { - child.kill("SIGKILL"); - resolve(); - }, 5_000); - child.once("exit", () => { - clearTimeout(timer); - resolve(); - }); - child.kill("SIGTERM"); - }); -} - -async function connectVerifier(url, token) { - const rpc = new PondGatewayRpc({ - url, - token, - scopes: ["operator.read", "operator.write", "operator.pairing", "operator.admin"], - }); - await rpc.connect(); - return rpc; -} - -async function waitFor(label, timeoutMs, fn) { - const deadline = now() + timeoutMs; - let lastError; - while (now() < deadline) { - try { - const value = await fn(); - if (value) { - return value; - } - } catch (err) { - lastError = err; - } - await new Promise((resolve) => { - setTimeout(resolve, 500); - }); - } - throw new Error(`${label} timed out${lastError ? `: ${lastError.message}` : ""}`); -} - -function connectedProofNodes(nodes) { - return (nodes ?? []).filter( - (node) => - Array.isArray(node.nodePluginTools) && - node.nodePluginTools.some((tool) => tool.pluginId === PLUGIN_ID && tool.name === "pond_echo"), - ); -} - -function isPondPairingRequest(request) { - const commands = Array.isArray(request?.commands) ? request.commands : []; - const nodeId = typeof request?.nodeId === "string" ? request.nodeId : ""; - const displayName = typeof request?.displayName === "string" ? request.displayName : ""; - return ( - commands.includes("pond.echo") && - (nodeId.startsWith("pond-") || displayName.startsWith("Pond ")) - ); -} - -async function approvePendingNodes(rpc) { - const list = await rpc.request("node.pair.list", {}); - const pending = Array.isArray(list?.pending) ? list.pending : []; - for (const request of pending) { - if (request?.requestId && isPondPairingRequest(request)) { - await rpc.request("node.pair.approve", { requestId: request.requestId }); - } - } - return pending.filter(isPondPairingRequest).length; -} - -async function waitForProofNodes(rpc, count, options = {}) { - let lastLogMs = 0; - return await waitFor(`connected proof nodes >= ${count}`, 60_000, async () => { - if (options.approve !== false) { - await approvePendingNodes(rpc); - } - const result = await rpc.request("node.list", {}); - const nodes = connectedProofNodes(result?.nodes); - if (verboseOutput && now() - lastLogMs > 5_000) { - lastLogMs = now(); - console.error( - "[pond-proof] node.list", - JSON.stringify( - (result?.nodes ?? []).map((node) => ({ - nodeId: node.nodeId, - displayName: node.displayName, - status: node.status, - connected: node.connected, - commands: node.commands, - nodePluginTools: node.nodePluginTools, - nodeSkills: node.nodeSkills, - })), - ), - ); - } - return nodes.length >= count ? nodes : null; - }); -} - -async function readPondPairingState(rpc, nodeId) { - const [deviceList, nodeList] = await Promise.all([ - rpc.request("device.pair.list", {}), - rpc.request("node.pair.list", {}), - ]); - const pending = (Array.isArray(nodeList?.pending) ? nodeList.pending : []).filter( - (entry) => entry?.nodeId === nodeId, - ); - const paired = (Array.isArray(deviceList?.paired) ? deviceList.paired : []).filter( - (entry) => entry?.deviceId === nodeId, - ); - return { paired, pending }; -} - -function assertPairingDidNotChange(before, after) { - if (before.paired.length !== 1 || after.paired.length !== 1) { - throw new Error( - `expected one durable pond pairing before/after restart: ${JSON.stringify({ before, after })}`, - ); - } - if (after.pending.length !== 0) { - throw new Error(`hot-plug created a new pairing prompt: ${JSON.stringify(after.pending)}`); - } - if (before.paired[0]?.deviceId !== after.paired[0]?.deviceId) { - throw new Error(`hot-plug changed paired node identity: ${JSON.stringify({ before, after })}`); - } -} - -function flattenEffectiveTools(result) { - return (result?.groups ?? []).flatMap((group) => - (group.tools ?? []).map((tool) => Object.assign({}, tool, { groupId: group.id })), - ); -} - -async function readEffectiveProofTools(rpc) { - const result = await rpc.request("tools.effective", { sessionKey: SESSION_KEY }); - return flattenEffectiveTools(result).filter( - (tool) => - tool.pluginId === PLUGIN_ID && (tool.id === "pond_echo" || tool.id.endsWith("_pond_echo")), - ); -} - -async function readEffectiveMcpProofTools(rpc) { - const result = await rpc.request("tools.effective", { sessionKey: SESSION_KEY }); - return flattenEffectiveTools(result).filter( - (tool) => tool.pluginId === MCP_PLUGIN_ID && tool.id.startsWith("pond_"), - ); -} - -async function readEffectiveToolById(rpc, toolId) { - const result = await rpc.request("tools.effective", { sessionKey: SESSION_KEY }); - return flattenEffectiveTools(result).find((tool) => tool.id === toolId); -} - -async function readEffectiveSharedProofTools(rpc) { - const result = await rpc.request("tools.effective", { sessionKey: SESSION_KEY }); - return flattenEffectiveTools(result).filter( - (tool) => tool.pluginId === PLUGIN_ID && tool.id.endsWith(`_${SHARED_TOOL_NAME}`), - ); -} - -async function readProofSkills(rpc, skillName = SKILL_NAME) { - const result = await rpc.request("skills.status", { agentId: "main" }); - const locatorSuffix = `/skills/${skillName}/SKILL.md`; - return (result?.skills ?? []).filter( - (skill) => - skill.name === skillName && - skill.source === "openclaw-node" && - skill.filePath.startsWith("node://") && - skill.filePath.endsWith(locatorSuffix), - ); -} - -async function invokeProofTools(rpc, tools) { - const outputs = []; - for (const tool of tools) { - const result = await rpc.request( - "tools.invoke", - { - name: tool.id, - sessionKey: SESSION_KEY, - args: { message: `from-${tool.id}` }, - idempotencyKey: `pond-${tool.id}-${now()}`, - }, - { timeoutMs: 45_000 }, - ); - if (!result?.ok) { - throw new Error(`tools.invoke failed for ${tool.id}: ${JSON.stringify(result)}`); - } - outputs.push({ tool: tool.id, output: result.output?.details ?? result.output }); - } - return outputs; -} - -async function invokeMcpProofTool(rpc, tool, proof) { - const result = await rpc.request( - "tools.invoke", - { - name: tool.id, - sessionKey: SESSION_KEY, - args: { text: proof }, - idempotencyKey: `pond-mcp-${now()}`, - }, - { timeoutMs: 45_000 }, - ); - if (!result?.ok) { - throw new Error(`tools.invoke failed for ${tool.id}: ${JSON.stringify(result)}`); - } - const output = result.output?.details ?? result.output; - const texts = (output?.content ?? []) - .filter((block) => block?.type === "text" && typeof block.text === "string") - .map((block) => block.text); - if (!texts.some((text) => text.includes(proof))) { - throw new Error(`MCP proof token missing from ${tool.id} output: ${JSON.stringify(output)}`); - } - return { tool: tool.id, output }; -} - -function assertToolIds(label, tools, expectedIds) { - const actual = tools.map((tool) => tool.id).toSorted((a, b) => a.localeCompare(b)); - const expected = [...expectedIds].toSorted((a, b) => a.localeCompare(b)); - if (JSON.stringify(actual) !== JSON.stringify(expected)) { - throw new Error(`${label}: expected ${expected.join(", ")}, got ${actual.join(", ")}`); - } -} - -function expectedNodeToolId(nodeId, baseName) { - let fragment = nodeId - .trim() - .toLowerCase() - .replace(/[^a-z0-9_]+/g, "_") - .replace(/^_+|_+$/g, "") - .slice(0, 32); - fragment = fragment || "node"; - if (!/^[a-z]/.test(fragment)) { - fragment = `node_${fragment}`.slice(0, 32); - } - return `${fragment}_${baseName}`; -} - -async function runLiveAgentTurn(rpc, { label, message, sessionKey }) { - const idempotencyKey = `pond-live-${label}-${crypto.randomBytes(8).toString("hex")}`; - const payload = await rpc.request( - "agent", - { - sessionKey, - idempotencyKey, - message, - deliver: false, - timeout: LIVE_TURN_TIMEOUT_MS / 1_000, - }, - { expectFinal: true, timeoutMs: LIVE_TURN_TIMEOUT_MS }, - ); - if (payload?.status !== "ok") { - throw new Error(`${label} agent turn failed: ${JSON.stringify(payload)}`); - } - const { extractAgentReplyTexts } = await import("./lib/agent-turn-output.mjs"); - const replies = [ - ...new Set( - extractAgentReplyTexts(JSON.stringify(payload)) - .map((reply) => reply.trim()) - .filter(Boolean), - ), - ]; - const reply = replies.join("\n").trim(); - if (!reply) { - throw new Error(`${label} agent turn returned no assistant text: ${JSON.stringify(payload)}`); - } - return { reply, runId: payload.runId }; -} - -async function readSessionToolCalls(rpc, sessionKey) { - const history = await rpc.request("chat.history", { sessionKey, limit: 64 }); - return (history?.messages ?? []).flatMap((message) => { - if (message?.role !== "assistant" || !Array.isArray(message.content)) { - return []; - } - return message.content.filter( - (block) => block?.type === "toolCall" && typeof block.name === "string", - ); - }); -} - -async function assertSessionUsedTool(rpc, sessionKey, toolName, argumentsMatch) { - const calls = await readSessionToolCalls(rpc, sessionKey); - const matching = calls.find( - (call) => call.name === toolName && (!argumentsMatch || argumentsMatch(call.arguments)), - ); - if (!matching) { - throw new Error(`session ${sessionKey} did not call ${toolName}: ${JSON.stringify(calls)}`); - } - return matching; -} - -async function assertSessionUsedNodeExec(rpc, sessionKey, nodeId, commandPath) { - const calls = await readSessionToolCalls(rpc, sessionKey); - const matching = calls.find((call) => { - const args = call.arguments; - if (call.name === "node_exec") { - return ( - (!args?.node || args.node === nodeId) && String(args?.command ?? "").includes(commandPath) - ); - } - return ( - call.name === "exec" && - args?.host === "node" && - (!args.node || args.node === nodeId) && - String(args?.command ?? "").includes(commandPath) - ); - }); - if (!matching) { - throw new Error( - `session ${sessionKey} did not execute on node ${nodeId}: ${JSON.stringify(calls)}`, - ); - } - return matching; -} - -async function runLiveChecks({ rpc, mcpTool, mcpCallLogPath, nodes, skillMarker, liveModel }) { - if (!mcpTool || !mcpCallLogPath) { - throw new Error("live MCP proof requires a published tool and call-log path"); - } - const startedAt = now(); - logStep(`running live agent turns with ${liveModel}`); - - const mcpToken = proofToken(); - const mcpTurn = await runLiveAgentTurn(rpc, { - label: "mcp", - sessionKey: "agent:main:pond-live-mcp", - message: `Call the tool ${mcpTool.id} with text ${JSON.stringify(mcpToken)}. Reply with exactly the text the tool returned. Do not call any other tool.`, - }); - if (!mcpTurn.reply.includes(mcpToken)) { - throw new Error(`live MCP reply missing ${mcpToken}: ${JSON.stringify(mcpTurn.reply)}`); - } - await waitFor("live MCP fixture call log", 10_000, async () => { - try { - return (await fs.readFile(mcpCallLogPath, "utf8")).includes(mcpToken); - } catch { - return false; - } - }); - - const skillTurn = await runLiveAgentTurn(rpc, { - label: "skill", - sessionKey: "agent:main:pond-live-skill", - message: `What is the pond marker? Follow the ${SKILL_NAME} skill. Reply with exactly the marker and nothing else.`, - }); - if (!skillTurn.reply.includes(skillMarker)) { - throw new Error(`live skill reply missing ${skillMarker}: ${JSON.stringify(skillTurn.reply)}`); - } - - const sharedTools = await waitFor("two disambiguated shared proof tools", 30_000, async () => { - const tools = await readEffectiveSharedProofTools(rpc); - return tools.length === 2 ? tools : null; - }); - const pondANode = nodes.find((node) => node.displayName === "Pond A"); - const pondBNode = nodes.find((node) => node.displayName === "Pond B"); - if (!pondANode?.nodeId || !pondBNode?.nodeId) { - throw new Error( - `live shared-tool proof could not resolve Pond A/B nodes: ${JSON.stringify(nodes)}`, - ); - } - const expectedSharedIds = [ - expectedNodeToolId(pondANode.nodeId, SHARED_TOOL_NAME), - expectedNodeToolId(pondBNode.nodeId, SHARED_TOOL_NAME), - ]; - assertToolIds("shared proof tool names", sharedTools, expectedSharedIds); - const pondBTool = sharedTools.find((tool) => tool.id === expectedSharedIds[1]); - if (!pondBTool) { - throw new Error("pond-b shared proof tool missing after deterministic-name assertion"); - } - const sharedTurn = await runLiveAgentTurn(rpc, { - label: "shared", - sessionKey: "agent:main:pond-live-shared", - message: `Call the tool ${pondBTool.id} with no arguments. Reply with exactly its output. Do not call any other tool.`, - }); - const sharedMarker = "pond-b-shared-ok"; - if (!sharedTurn.reply.includes(sharedMarker)) { - throw new Error( - `live shared-tool reply missing ${sharedMarker}: ${JSON.stringify(sharedTurn.reply)}`, - ); - } - - return { - model: liveModel, - durationMs: now() - startedAt, - mcp: { tool: mcpTool.id, runId: mcpTurn.runId, proofToken: mcpToken }, - skill: { name: SKILL_NAME, runId: skillTurn.runId, marker: skillMarker }, - shared: { tools: sharedTools, selected: pondBTool.id, runId: sharedTurn.runId, sharedMarker }, - }; -} - -function pondNodeArgs(port) { - return [ - "node", - "run", - "--host", - "127.0.0.1", - "--port", - String(port), - "--node-id", - "pond-hot-plug", - "--display-name", - "Pond Hot Plug", - ]; -} - -async function runHotPlugLocal(args) { - if (args.live !== true) { - throw new Error("--hot-plug requires --live"); - } - const liveModel = String( - args.model || process.env.OPENCLAW_POND_LIVE_MODEL || DEFAULT_HOT_PLUG_LIVE_MODEL, - ); - const token = String(args.token || proofToken()); - const mcpProofToken = proofToken(); - const skillProofToken = proofToken(); - const hotPlugSkillName = `${SKILL_NAME}-${skillProofToken.slice(-8)}`; - const port = args.port ? Number(args.port) : await availableLoopbackPort(); - const baseDir = String( - args.baseDir || (await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-node-hot-plug-"))), - ); - const gatewayState = await prepareRoleState(baseDir, "gateway", token, "gateway", { - liveModel, - }); - const nodeState = await prepareRoleState(baseDir, "pond-hot-plug", token, "pond-hot-plug"); - const children = []; - let nodeChild; - if (args.build === true) { - logStep("building OpenClaw"); - await runCommand("pnpm", ["build"], { - stdio: args.verbose ? "inherit" : ["ignore", "ignore", "ignore"], - }); - } else { - logStep("using existing OpenClaw build (pass --build to rebuild)"); - } - const childOptions = (state, label) => ({ - env: childEnv(state, token, label), - stdio: args.verbose ? "inherit" : ["ignore", "ignore", "ignore"], - built: true, - onExit: (code, signal) => { - if (args.verbose) { - console.error(`[${label}] exit code=${code} signal=${signal}`); - } - }, - }); - const startNode = (label) => { - const options = childOptions(nodeState, label); - options.env = { - ...options.env, - HOME: nodeState.rootDir, - npm_config_cache: process.env.npm_config_cache || path.join(os.homedir(), ".npm"), - }; - nodeChild = spawnOpenClaw(pondNodeArgs(port), options); - children.push(nodeChild); - return nodeChild; - }; - const stopNode = async () => { - const child = nodeChild; - nodeChild = undefined; - if (!child) { - return; - } - const index = children.indexOf(child); - if (index >= 0) { - children.splice(index, 1); - } - await terminate(child); - }; - try { - logStep("starting cold gateway and node host with no MCP servers or skills"); - children.push( - spawnOpenClaw( - [ - "gateway", - "run", - "--allow-unconfigured", - "--auth", - "token", - "--bind", - "loopback", - "--port", - String(port), - "--ws-log", - "compact", - ], - childOptions(gatewayState, "gateway"), - ), - ); - const url = `ws://127.0.0.1:${port}`; - await waitFor("gateway RPC", 60_000, async () => { - const rpc = await connectVerifier(url, token); - rpc.close(); - return true; - }); - startNode("pond-hot-plug-cold"); - const rpc = await connectVerifier(url, token); - try { - const coldNodes = await waitForProofNodes(rpc, 1); - const pairedNodeId = coldNodes[0]?.nodeId; - if (!pairedNodeId) { - throw new Error(`cold node identity missing: ${JSON.stringify(coldNodes)}`); - } - const coldPairing = await readPondPairingState(rpc, pairedNodeId); - if (coldPairing.paired.length !== 1 || coldPairing.pending.length !== 0) { - throw new Error(`cold pairing did not settle: ${JSON.stringify(coldPairing)}`); - } - if ((await readEffectiveMcpProofTools(rpc)).length !== 0) { - throw new Error("cold node unexpectedly published MCP tools"); - } - if ((await readProofSkills(rpc, hotPlugSkillName)).length !== 0) { - throw new Error("cold node unexpectedly published skills"); - } - const coldSessionKey = "agent:main:pond-hot-plug-cold"; - const coldTurn = await runLiveAgentTurn(rpc, { - label: "hot-plug-cold", - sessionKey: coldSessionKey, - message: `Inspect your actual tool inventory. If ${FILESYSTEM_MCP_TOOL_NAME} is available, reply exactly MCP_PRESENT. Otherwise reply exactly MCP_ABSENT. Do not call any tool.`, - }); - if (coldTurn.reply.trim() !== "MCP_ABSENT") { - throw new Error(`cold live turn hallucinated MCP availability: ${coldTurn.reply}`); - } - - logStep("adding filesystem MCP, slow MCP, and node skill; restarting node host only"); - const surfaces = await enableHotPlugNodeSurfaces(nodeState, { - mcpProofToken, - skillProofToken, - skillName: hotPlugSkillName, - }); - await stopNode(); - startNode("pond-hot-plug-added"); - await waitForProofNodes(rpc, 1, { approve: false }); - const addedPairing = await readPondPairingState(rpc, pairedNodeId); - assertPairingDidNotChange(coldPairing, addedPairing); - const filesystemTool = await waitFor( - "filesystem MCP tool after hot-plug", - 60_000, - async () => await readEffectiveToolById(rpc, FILESYSTEM_MCP_TOOL_NAME), - ); - const slowTool = await waitFor( - "slow MCP tool after hot-plug", - 30_000, - async () => await readEffectiveToolById(rpc, `${MCP_SERVER_NAME}_${MCP_SLOW_TOOL_NAME}`), - ); - await waitFor("node skill after hot-plug", 30_000, async () => { - const skills = await readProofSkills(rpc, hotPlugSkillName); - return skills.length === 1 ? skills : null; - }); - - logStep("asking the live model to read the node-local file through filesystem MCP"); - let mcpTurn; - let mcpSessionKey; - let mcpAttempts = 0; - for (let attempt = 1; attempt <= 2; attempt += 1) { - mcpAttempts = attempt; - mcpSessionKey = `agent:main:pond-hot-plug-mcp-${attempt}`; - mcpTurn = await runLiveAgentTurn(rpc, { - label: `hot-plug-filesystem-${attempt}`, - sessionKey: mcpSessionKey, - message: `You have the ${filesystemTool.id} tool. Call it now with path ${JSON.stringify(surfaces.mcpProofFilePath)}. Reply with exactly the file contents and nothing else. Do not call any other tool.`, - }); - const calls = await readSessionToolCalls(rpc, mcpSessionKey); - if ( - mcpTurn.reply.includes(mcpProofToken) && - calls.some((call) => call.name === filesystemTool.id) - ) { - break; - } - if (attempt === 1) { - logStep(`filesystem live turn did not call the tool; retrying once: ${mcpTurn.reply}`); - } - } - if (!mcpTurn?.reply.includes(mcpProofToken) || !mcpSessionKey) { - throw new Error(`hot-plug filesystem reply missing node token: ${mcpTurn?.reply ?? ""}`); - } - await assertSessionUsedTool(rpc, mcpSessionKey, filesystemTool.id); - - logStep("asking the live model to follow the node-hosted remote-exec skill"); - const skillSessionKey = "agent:main:pond-hot-plug-skill"; - const skillTurn = await runLiveAgentTurn(rpc, { - label: "hot-plug-skill", - sessionKey: skillSessionKey, - message: `Use the ${hotPlugSkillName} skill to get the pond node-local marker. Reply with exactly the marker.`, - }); - if (!skillTurn.reply.includes(skillProofToken)) { - throw new Error(`hot-plug skill reply missing node token: ${skillTurn.reply}`); - } - await assertSessionUsedNodeExec( - rpc, - skillSessionKey, - pairedNodeId, - surfaces.skillProofFilePath, - ); - - logStep( - "starting slow MCP call, killing node host after server dispatch, and waiting for degradation", - ); - let chaosToken; - let chaosTurn; - let chaosDurationMs; - for (let attempt = 1; attempt <= 2; attempt += 1) { - chaosToken = proofToken(); - const chaosSessionKey = `agent:main:pond-hot-plug-chaos-${attempt}`; - const chaosStartedAt = now(); - const chaosTurnPromise = runLiveAgentTurn(rpc, { - label: `hot-plug-chaos-${attempt}`, - sessionKey: chaosSessionKey, - message: `Mandatory tool execution test: call ${slowTool.id} now with text ${JSON.stringify(chaosToken)}. Do not reply before this tool call returns. After the call, reply exactly DEGRADED if it returned an error, otherwise reply exactly SUCCESS. A reply without the tool call is invalid. Do not call any other tool.`, - }); - const dispatchPromise = waitFor("slow MCP call started", 60_000, async () => { - try { - return (await fs.readFile(surfaces.slowCallLogPath, "utf8")).includes(chaosToken); - } catch { - return false; - } - }).then( - () => ({ dispatched: true }), - () => ({ dispatched: false }), - ); - const first = await Promise.race([ - dispatchPromise, - chaosTurnPromise.then((turn) => ({ dispatched: false, turn })), - ]); - if (!first.dispatched) { - const turn = "turn" in first ? first.turn : await chaosTurnPromise; - if (attempt === 2) { - throw new Error(`live model did not dispatch ${slowTool.id}: ${turn.reply}`); - } - logStep(`slow MCP live turn did not call the tool; retrying once: ${turn.reply}`); - continue; - } - await new Promise((resolve) => { - setTimeout(resolve, 3_000); - }); - await stopNode(); - chaosTurn = await chaosTurnPromise; - chaosDurationMs = now() - chaosStartedAt; - break; - } - if (!chaosTurn || chaosTurn.reply.trim() !== "DEGRADED") { - throw new Error(`mid-turn node loss did not degrade cleanly: ${chaosTurn?.reply ?? ""}`); - } - - logStep("reconnecting node host and retrying the slow MCP call"); - startNode("pond-hot-plug-retry"); - await waitForProofNodes(rpc, 1, { approve: false }); - assertPairingDidNotChange(coldPairing, await readPondPairingState(rpc, pairedNodeId)); - const retrySlowTool = await waitFor( - "slow MCP tool after reconnect", - 30_000, - async () => await readEffectiveToolById(rpc, slowTool.id), - ); - const retryToken = proofToken(); - const retrySessionKey = "agent:main:pond-hot-plug-retry"; - const retryTurn = await runLiveAgentTurn(rpc, { - label: "hot-plug-retry", - sessionKey: retrySessionKey, - message: `Call ${retrySlowTool.id} with text ${JSON.stringify(retryToken)}. Reply with exactly the returned text and nothing else. Do not call any other tool.`, - }); - if (!retryTurn.reply.includes(retryToken)) { - throw new Error(`retry reply missing MCP token: ${retryTurn.reply}`); - } - await assertSessionUsedTool(rpc, retrySessionKey, retrySlowTool.id); - - logStep("removing MCP servers and skill; restarting node host and checking symmetry"); - await disableHotPlugNodeSurfaces(nodeState, hotPlugSkillName); - await stopNode(); - startNode("pond-hot-plug-removed"); - await waitForProofNodes(rpc, 1, { approve: false }); - const removedPairing = await readPondPairingState(rpc, pairedNodeId); - assertPairingDidNotChange(coldPairing, removedPairing); - await waitFor("all MCP tools removed", 30_000, async () => { - const filesystem = await readEffectiveToolById(rpc, filesystemTool.id); - const slow = await readEffectiveToolById(rpc, slowTool.id); - return !filesystem && !slow; - }); - await waitFor( - "node skill removed", - 30_000, - async () => (await readProofSkills(rpc, hotPlugSkillName)).length === 0, - ); - const removedTurn = await runLiveAgentTurn(rpc, { - label: "hot-plug-removed", - sessionKey: "agent:main:pond-hot-plug-removed", - message: `Inspect your actual tools and skills. Reply exactly MCP_ABSENT|SKILL_ABSENT if ${filesystemTool.id} and ${hotPlugSkillName} are both unavailable. Do not call any tool.`, - }); - if (removedTurn.reply.trim() !== "MCP_ABSENT|SKILL_ABSENT") { - throw new Error(`removal live turn retained stale surfaces: ${removedTurn.reply}`); - } - - logStep("hot-plug tour passed"); - console.log( - JSON.stringify( - { - ok: true, - scenario: "hot-plug-tour", - provider: "local-process", - model: liveModel, - baseDir, - pairing: { - nodeId: coldPairing.paired[0]?.deviceId, - approvedCommands: coldPairing.paired[0]?.nodeSurface?.commands, - pendingAfterAdd: addedPairing.pending.length, - pendingAfterRemove: removedPairing.pending.length, - }, - cold: { runId: coldTurn.runId, reply: coldTurn.reply }, - mcp: { - tool: filesystemTool.id, - runId: mcpTurn.runId, - marker: mcpProofToken, - attempts: mcpAttempts, - }, - skill: { name: hotPlugSkillName, runId: skillTurn.runId, marker: skillProofToken }, - chaos: { - tool: slowTool.id, - runId: chaosTurn.runId, - reply: chaosTurn.reply, - durationMs: chaosDurationMs, - }, - retry: { runId: retryTurn.runId, marker: retryToken }, - removed: { runId: removedTurn.runId, reply: removedTurn.reply }, - }, - null, - 2, - ), - ); - } finally { - rpc.close(); - } - } finally { - await Promise.all(children.map((child) => terminate(child))); - } -} - -async function runVerify({ url, token, expectedNodes }) { - const rpc = await connectVerifier(url, token); - try { - const nodes = await waitForProofNodes(rpc, expectedNodes); - const tools = await waitFor(`effective proof tools >= ${expectedNodes}`, 30_000, async () => { - const value = await readEffectiveProofTools(rpc); - return value.length >= expectedNodes ? value : null; - }); - const outputs = await invokeProofTools(rpc, tools); - const labels = new Set(outputs.map((entry) => entry.output?.nodeLabel).filter(Boolean)); - if (labels.size < expectedNodes) { - throw new Error(`expected ${expectedNodes} node labels, got ${[...labels].join(",")}`); - } - console.log( - JSON.stringify( - { - ok: true, - nodes: nodes.map((node) => ({ - nodeId: node.nodeId, - displayName: node.displayName, - tools: node.nodePluginTools, - })), - effectiveTools: tools, - outputs, - }, - null, - 2, - ), - ); - } finally { - rpc.close(); - } -} - -async function runGateway(args) { - const token = String(args.token || process.env.OPENCLAW_GATEWAY_TOKEN || ""); - if (!token) { - throw new Error("--token or OPENCLAW_GATEWAY_TOKEN required"); - } - const port = Number(args.port || DEFAULT_PORT); - const baseDir = String( - args.baseDir || (await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-node-plugin-tools-"))), - ); - const state = await prepareRoleState(baseDir, "gateway", token, "gateway"); - console.log(JSON.stringify({ role: "gateway", port, tokenSet: true, stateDir: state.stateDir })); - const child = spawnOpenClaw( - [ - "gateway", - "run", - "--allow-unconfigured", - "--auth", - "token", - "--bind", - "lan", - "--port", - String(port), - "--ws-log", - "compact", - ], - { env: childEnv(state, token, "gateway") }, - ); - await runForegroundChild(child); -} - -async function runNode(args) { - const token = String(args.token || process.env.OPENCLAW_GATEWAY_TOKEN || ""); - if (!token) { - throw new Error("--token or OPENCLAW_GATEWAY_TOKEN required"); - } - const host = String(args.host || "127.0.0.1"); - const port = Number(args.port || DEFAULT_PORT); - const nodeId = String(args.nodeId || `pond-${crypto.randomBytes(4).toString("hex")}`); - const displayName = String(args.displayName || nodeId); - const baseDir = String( - args.baseDir || (await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-node-plugin-tools-"))), - ); - const state = await prepareRoleState(baseDir, nodeId, token, nodeId, { - nodeSurfaces: args["plugin-only"] !== true, - skillProofToken: String(args.skillProofToken || proofToken()), - }); - console.log(JSON.stringify({ role: "node", nodeId, host, port, tokenSet: true })); - const child = spawnOpenClaw( - [ - "node", - "run", - "--host", - host, - "--port", - String(port), - "--node-id", - nodeId, - "--display-name", - displayName, - ], - { env: childEnv(state, token, nodeId) }, - ); - if (args.lifetimeMs) { - setTimeout(() => { - child.kill("SIGTERM"); - }, Number(args.lifetimeMs)); - } - await runForegroundChild(child); -} - -async function runLocal(args) { - const live = args.live === true; - const liveModel = String( - args.model || process.env.OPENCLAW_POND_LIVE_MODEL || DEFAULT_LIVE_MODEL, - ); - const token = String(args.token || proofToken()); - const mcpProofToken = proofToken(); - const skillProofToken = proofToken(); - const port = args.port ? Number(args.port) : await availableLoopbackPort(); - const baseDir = String( - args.baseDir || (await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-node-plugin-tools-"))), - ); - const gatewayState = await prepareRoleState(baseDir, "gateway", token, "gateway", { - liveModel: live ? liveModel : undefined, - sharedTool: live, - }); - const nodeAState = await prepareRoleState(baseDir, "pond-a", token, "pond-a", { - sharedTool: live, - }); - const nodeBState = await prepareRoleState(baseDir, "pond-b", token, "pond-b", { - nodeSurfaces: true, - sharedTool: live, - skillProofToken, - }); - const children = []; - if (args.build === true) { - logStep("building OpenClaw"); - await runCommand("pnpm", ["build"], { - stdio: args.verbose ? "inherit" : ["ignore", "ignore", "ignore"], - }); - } else { - logStep("using existing OpenClaw build (pass --build to rebuild)"); - } - const childOptions = (state, label) => ({ - env: childEnv(state, token, label), - stdio: args.verbose ? "inherit" : ["ignore", "ignore", "ignore"], - built: true, - onExit: (code, signal) => { - if (args.verbose) { - console.error(`[${label}] exit code=${code} signal=${signal}`); - } - }, - }); - try { - logStep("starting gateway"); - children.push( - spawnOpenClaw( - [ - "gateway", - "run", - "--allow-unconfigured", - "--auth", - "token", - "--bind", - "loopback", - "--port", - String(port), - "--ws-log", - "compact", - ], - childOptions(gatewayState, "gateway"), - ), - ); - const url = `ws://127.0.0.1:${port}`; - await waitFor("gateway RPC", 60_000, async () => { - const rpc = await connectVerifier(url, token); - rpc.close(); - return true; - }); - logStep("starting pond-a and pond-b node hosts"); - children.push( - spawnOpenClaw( - [ - "node", - "run", - "--host", - "127.0.0.1", - "--port", - String(port), - "--node-id", - "pond-a", - "--display-name", - "Pond A", - ], - childOptions(nodeAState, "pond-a"), - ), - ); - children.push( - spawnOpenClaw( - [ - "node", - "run", - "--host", - "127.0.0.1", - "--port", - String(port), - "--node-id", - "pond-b", - "--display-name", - "Pond B", - ], - childOptions(nodeBState, "pond-b"), - ), - ); - const rpc = await connectVerifier(url, token); - try { - logStep("waiting for node plugin tools, MCP tool, and node skill publication"); - await waitForProofNodes(rpc, 2); - const initialTools = await waitFor("two effective proof tools", 30_000, async () => { - const tools = await readEffectiveProofTools(rpc); - return tools.length === 2 ? tools : null; - }); - const initialOutputs = await invokeProofTools(rpc, initialTools); - const initialMcpTools = await waitFor("one effective MCP proof tool", 30_000, async () => { - const tools = await readEffectiveMcpProofTools(rpc); - return tools.length === 1 ? tools : null; - }); - const initialMcpOutput = await invokeMcpProofTool(rpc, initialMcpTools[0], mcpProofToken); - const initialSkills = await waitFor("node skill in skills.status", 30_000, async () => { - const skills = await readProofSkills(rpc); - return skills.length === 1 ? skills : null; - }); - if (!(await fs.readFile(nodeBState.skillPath, "utf8")).includes(skillProofToken)) { - throw new Error("node skill fixture proof token missing before publication assertion"); - } - logStep("stopping pond-b and verifying all three published surfaces disappear"); - await terminate(children.pop()); - await waitFor("pond-b offline", 30_000, async () => { - const result = await rpc.request("node.list", {}); - return connectedProofNodes(result?.nodes).length === 1; - }); - const afterOfflineTools = await waitFor( - "one effective proof tool after offline", - 30_000, - async () => { - const tools = await readEffectiveProofTools(rpc); - return tools.length === 1 ? tools : null; - }, - ); - await waitFor("MCP proof tool removed after offline", 30_000, async () => { - const tools = await readEffectiveMcpProofTools(rpc); - return tools.length === 0; - }); - await waitFor("node skill removed after offline", 30_000, async () => { - const skills = await readProofSkills(rpc); - return skills.length === 0; - }); - logStep("restarting pond-b and verifying publication returns"); - const restartedB = spawnOpenClaw( - [ - "node", - "run", - "--host", - "127.0.0.1", - "--port", - String(port), - "--node-id", - "pond-b", - "--display-name", - "Pond B", - ], - childOptions(nodeBState, "pond-b-restart"), - ); - children.push(restartedB); - const reconnectedNodes = await waitForProofNodes(rpc, 2); - const afterReconnectTools = await waitFor( - "two effective proof tools after reconnect", - 30_000, - async () => { - const tools = await readEffectiveProofTools(rpc); - return tools.length === 2 ? tools : null; - }, - ); - const afterReconnectOutputs = await invokeProofTools(rpc, afterReconnectTools); - const afterReconnectMcpTools = await waitFor( - "MCP proof tool after reconnect", - 30_000, - async () => { - const tools = await readEffectiveMcpProofTools(rpc); - return tools.length === 1 ? tools : null; - }, - ); - const afterReconnectMcpOutput = await invokeMcpProofTool( - rpc, - afterReconnectMcpTools[0], - mcpProofToken, - ); - const afterReconnectSkills = await waitFor("node skill after reconnect", 30_000, async () => { - const skills = await readProofSkills(rpc); - return skills.length === 1 ? skills : null; - }); - const liveResult = live - ? await runLiveChecks({ - rpc, - mcpTool: afterReconnectMcpTools[0], - mcpCallLogPath: nodeBState.mcpCallLogPath, - nodes: reconnectedNodes, - skillMarker: skillProofToken, - liveModel, - }) - : undefined; - logStep("all node-hosted surface checks passed"); - console.log( - JSON.stringify( - { - ok: true, - provider: "local-process", - baseDir, - initialTools, - initialOutputs, - initialMcpTools, - initialMcpOutput, - initialSkills, - afterOfflineTools, - afterReconnectTools, - afterReconnectOutputs, - afterReconnectMcpTools, - afterReconnectMcpOutput, - afterReconnectSkills, - ...(liveResult ? { live: liveResult } : {}), - }, - null, - 2, - ), - ); - } finally { - rpc.close(); - } - } finally { - await Promise.all(children.map((child) => terminate(child))); - } -} - -async function main() { - const args = parseArgs(process.argv.slice(2)); - verboseOutput = args.verbose === true; - const mode = args._[0] ?? "local"; - const defaultLiveModel = - args["hot-plug"] === true ? DEFAULT_HOT_PLUG_LIVE_MODEL : DEFAULT_LIVE_MODEL; - const liveModel = String(args.model || process.env.OPENCLAW_POND_LIVE_MODEL || defaultLiveModel); - const liveProvider = liveModel.split("/", 1)[0]; - const liveKeyPresent = - liveProvider === "openai" - ? Boolean(process.env.OPENAI_API_KEY?.trim() || process.env.OPENCLAW_LIVE_OPENAI_KEY?.trim()) - : liveProvider === "anthropic" - ? Boolean(process.env.ANTHROPIC_API_KEY?.trim()) - : true; - if (args.live === true && !liveKeyPresent) { - console.error(`--live with ${liveModel} requires the matching provider API key`); - process.exitCode = 2; - return; - } - if (mode === "gateway") { - await runGateway(args); - return; - } - if (mode === "node") { - await runNode(args); - return; - } - if (mode === "verify") { - const token = String(args.token || process.env.OPENCLAW_GATEWAY_TOKEN || ""); - if (!token) { - throw new Error("--token or OPENCLAW_GATEWAY_TOKEN required"); - } - await runVerify({ - url: String(args.url || `ws://127.0.0.1:${args.port || DEFAULT_PORT}`), - token, - expectedNodes: Number(args.expectedNodes || 2), - }); - return; - } - if (mode === "local") { - if (args["hot-plug"] === true) { - await runHotPlugLocal(args); - } else { - await runLocal(args); - } - return; - } - throw new Error( - "usage: node scripts/e2e/node-plugin-tools-pond.mjs [local|gateway|node|verify] [--build] [--live] [--hot-plug] [--model provider/model]", - ); -} - -main().catch( - /** @param {unknown} err */ (err) => { - console.error(err instanceof Error ? err.stack || err.message : String(err)); - process.exitCode = 1; - }, -); diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 79880f002df3..73b03b365e76 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -1737,10 +1737,6 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ "test/scripts/e2e-helper-env-limits.test.ts", ], ], - [ - "scripts/e2e/channel-plugin-trust-docker.sh", - ["test/scripts/docker-build-helper.test.ts", "test/scripts/test-projects.test.ts"], - ], [ "scripts/e2e/config-reload-source-docker.sh", [ @@ -3671,18 +3667,10 @@ function resolveK8sManifestTargets(changedPath) { function resolveParallelsToolingTestTargets(changedPath) { if ( !/^scripts\/e2e\/parallels\/[^/]+\.ts$/u.test(changedPath) && - !/^scripts\/e2e\/parallels-(?:linux|macos|npm-update|windows)-smoke\.sh$/u.test(changedPath) && - !/^scripts\/e2e\/lib\/parallels-package\/build-info-commit\.mjs$/u.test(changedPath) && - !/^scripts\/e2e\/lib\/parallels-(?:macos|package)-common\.sh$/u.test(changedPath) + !/^scripts\/e2e\/parallels-(?:linux|macos|npm-update|windows)-smoke\.sh$/u.test(changedPath) ) { return null; } - if ( - /^scripts\/e2e\/lib\/parallels-package\/build-info-commit\.mjs$/u.test(changedPath) || - /^scripts\/e2e\/lib\/parallels-(?:macos|package)-common\.sh$/u.test(changedPath) - ) { - return ["test/scripts/parallels-lib-helpers.test.ts"]; - } const targets = ["test/scripts/parallels-smoke-model.test.ts"]; if ( [ diff --git a/test/scripts/parallels-lib-helpers.test.ts b/test/scripts/parallels-lib-helpers.test.ts deleted file mode 100644 index 98311e3dbb1f..000000000000 --- a/test/scripts/parallels-lib-helpers.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; -import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { cleanupTempDirs, makeTempDir } from "../helpers/temp-dir.js"; - -const BUILD_INFO_COMMIT_SCRIPT = path.resolve( - "scripts/e2e/lib/parallels-package/build-info-commit.mjs", -); -const tempDirs: string[] = []; - -afterEach(() => { - cleanupTempDirs(tempDirs); -}); - -function shellQuote(value: string): string { - return `'${value.replaceAll("'", "'\\''")}'`; -} - -function runBash(script: string, env: NodeJS.ProcessEnv = {}) { - return spawnSync("/bin/bash", ["-c", script], { - cwd: process.cwd(), - encoding: "utf8", - env: { ...process.env, ...env }, - }); -} - -describe("Parallels lib helpers", () => { - it("reads build-info commit metadata from the current package cwd", () => { - const root = makeTempDir(tempDirs, "openclaw-parallels-build-info-"); - - const missingResult = spawnSync(process.execPath, [BUILD_INFO_COMMIT_SCRIPT], { - cwd: root, - encoding: "utf8", - env: { ...process.env }, - }); - expect(missingResult.status).toBe(0); - expect(missingResult.stdout).toBe("\n"); - - mkdirSync(path.join(root, "dist")); - writeFileSync( - path.join(root, "dist", "build-info.json"), - `${JSON.stringify({ commit: "abc123" })}\n`, - ); - const result = spawnSync(process.execPath, [BUILD_INFO_COMMIT_SCRIPT], { - cwd: root, - encoding: "utf8", - env: { ...process.env }, - }); - - expect(result.status).toBe(0); - expect(result.stdout).toBe("abc123\n"); - }); - - it("reclaims stale package shell locks and releases current locks", () => { - const root = makeTempDir(tempDirs, "openclaw-parallels-package-lock-"); - const lockDir = path.join(root, "build.lock"); - const result = runBash(` -set -euo pipefail -source scripts/e2e/lib/parallels-package-common.sh -lock_dir=${shellQuote(lockDir)} -mkdir -p "$lock_dir" -printf '%s\\n' 999999999 >"$lock_dir/pid" -parallels_package_acquire_build_lock "$lock_dir" -owner="$(cat "$lock_dir/pid")" -parallels_package_release_build_lock "$lock_dir" -printf 'owner=%s exists=%s\\n' "$owner" "$([[ -e "$lock_dir" ]] && echo yes || echo no)" -`); - - expect(result.status).toBe(0); - expect(result.stderr).toContain("warn: Removing stale Parallels build lock"); - expect(result.stdout).toMatch(/^owner=\d+ exists=no\n$/u); - expect(result.stdout).not.toContain("owner=999999999"); - }); - - it("resolves macOS desktop users through prlctl fallbacks", () => { - const root = makeTempDir(tempDirs, "openclaw-parallels-macos-common-"); - const binDir = path.join(root, "bin"); - const macHome = `${"/"}Users/alice`; - mkdirSync(binDir); - const prlctlShim = path.join(binDir, "prlctl"); - writeFileSync( - prlctlShim, - `#!/usr/bin/env bash -args="$*" -if [[ "$args" == *"/usr/bin/stat -f %Su /dev/console"* ]]; then - printf 'loginwindow\\r\\n' - exit 0 -fi -if [[ "$args" == *"/usr/bin/dscl . -list /Users NFSHomeDirectory"* ]]; then - printf '_daemon /var/root\\r\\nShared %s\\r\\nalice %s\\r\\n' "${`${"/"}Users/Shared`}" "${macHome}" - exit 0 -fi -if [[ "$args" == *"-read ${macHome} NFSHomeDirectory"* ]]; then - printf 'NFSHomeDirectory: %s\\r\\n' "${macHome}" - exit 0 -fi -exit 1 -`, - ); - chmodSync(prlctlShim, 0o755); - - const result = runBash( - ` -set -euo pipefail -source scripts/e2e/lib/parallels-macos-common.sh -printf 'user=%s\\n' "$(parallels_macos_resolve_desktop_user macos-vm)" -printf 'home=%s\\n' "$(parallels_macos_resolve_desktop_home macos-vm alice)" -`, - { PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}` }, - ); - - expect(result.status).toBe(0); - expect(result.stdout).toBe(`user=alice\nhome=${macHome}\n`); - }); -}); diff --git a/test/scripts/parallels-package-log-progress-extract.test.ts b/test/scripts/parallels-package-log-progress-extract.test.ts deleted file mode 100644 index 496e200f2583..000000000000 --- a/test/scripts/parallels-package-log-progress-extract.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -// Parallels Package Log Progress Extract tests cover parallels package log progress extract script behavior. -import { spawnSync } from "node:child_process"; -import { writeFileSync } from "node:fs"; -import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; - -const SCRIPT_PATH = "scripts/e2e/lib/parallels-package/log-progress-extract.mjs"; -const tempRoots = useAutoCleanupTempDirTracker(afterEach); - -function makeTempRoot(): string { - return tempRoots.make("openclaw-parallels-progress-"); -} - -function runExtract(logPath?: string) { - return spawnSync(process.execPath, [SCRIPT_PATH, ...(logPath ? [logPath] : [])], { - encoding: "utf8", - }); -} - -describe("parallels package log progress extractor", () => { - it("prints a blank status when the log is absent", () => { - const result = runExtract(path.join(makeTempRoot(), "missing.log")); - - expect(result.status).toBe(0); - expect(result.stdout).toBe("\n"); - }); - - it("extracts the latest progress line from recent log output", () => { - const logPath = path.join(makeTempRoot(), "phase.log"); - writeFileSync(logPath, "==> Build package\nwarn: transient\n==> Copy artifact\n"); - - const result = runExtract(logPath); - - expect(result.status).toBe(0); - expect(result.stdout).toBe("Copy artifact\n"); - }); - - it("does not let stale progress hide recent warnings in long logs", () => { - const logPath = path.join(makeTempRoot(), "phase.log"); - writeFileSync( - logPath, - `==> Stale build step\n${"ordinary output\n".repeat(24 * 1024)}warn: recent package issue\n`, - ); - - const result = runExtract(logPath); - - expect(result.status).toBe(0); - expect(result.stdout).toBe("warn: recent package issue\n"); - }); -}); diff --git a/test/scripts/pond-gateway-rpc.test.ts b/test/scripts/pond-gateway-rpc.test.ts deleted file mode 100644 index 4d1ad3614c5c..000000000000 --- a/test/scripts/pond-gateway-rpc.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { EventEmitter } from "node:events"; -import { describe, expect, it, vi } from "vitest"; -import { PondGatewayRpc } from "../../scripts/e2e/lib/pond-gateway-rpc.mjs"; - -const CONNECTING = 0; -const OPEN = 1; -const CLOSED = 3; - -class FakeWebSocket extends EventEmitter { - readyState = CONNECTING; - sent: Array<{ id: string; method: string }> = []; - terminated = false; - sendError: Error | undefined; - respondToConnect = true; - - open(): void { - this.readyState = OPEN; - this.emit("open"); - } - - send(payload: string, callback: (error?: Error) => void): void { - const frame = JSON.parse(payload) as { id: string; method: string }; - this.sent.push(frame); - callback(this.sendError); - if (frame.method === "connect" && !this.sendError && this.respondToConnect) { - queueMicrotask(() => { - this.emit("message", JSON.stringify({ type: "res", id: frame.id, ok: true })); - }); - } - } - - close(): void { - this.readyState = CLOSED; - this.emit("close", 1000, Buffer.alloc(0)); - } - - terminate(): void { - this.terminated = true; - this.readyState = CLOSED; - this.emit("close", 1006, Buffer.alloc(0)); - } -} - -function createRpc(socket: FakeWebSocket, openTimeoutMs = 100) { - return new PondGatewayRpc({ - url: "ws://127.0.0.1:18789", - token: String(), - scopes: ["operator.read"], - openTimeoutMs, - webSocketFactory: () => socket, - }); -} - -async function connect(rpc: PondGatewayRpc, socket: FakeWebSocket): Promise { - const connecting = rpc.connect(); - socket.open(); - await connecting; -} - -describe("Pond gateway RPC", () => { - it("terminates a stalled websocket handshake at the connection deadline", async () => { - const socket = new FakeWebSocket(); - const rpc = createRpc(socket, 1); - const keepAlive = setTimeout(() => {}, 100); - - try { - await expect(rpc.connect()).rejects.toThrow("Gateway connect timeout: ws://127.0.0.1:18789"); - } finally { - clearTimeout(keepAlive); - } - - expect(socket.terminated).toBe(true); - }); - - it("closes the websocket when the gateway connect RPC stalls", async () => { - vi.useFakeTimers(); - try { - const socket = new FakeWebSocket(); - socket.respondToConnect = false; - const rpc = createRpc(socket); - const connecting = rpc.connect(); - const rejected = expect(connecting).rejects.toThrow("Gateway RPC timeout: connect"); - socket.open(); - - await vi.advanceTimersByTimeAsync(30_000); - await rejected; - expect(socket.readyState).toBe(CLOSED); - } finally { - vi.useRealTimers(); - } - }); - - it("rejects pending RPCs when an open websocket emits an error", async () => { - const socket = new FakeWebSocket(); - const rpc = createRpc(socket); - await connect(rpc, socket); - - const request = rpc.request("node.list"); - socket.emit("error", new Error("invalid websocket frame")); - - await expect(request).rejects.toThrow("invalid websocket frame"); - await expect(rpc.request("node.list")).rejects.toThrow("invalid websocket frame"); - }); - - it("rejects pending RPCs when an open websocket closes", async () => { - const socket = new FakeWebSocket(); - const rpc = createRpc(socket); - await connect(rpc, socket); - - const request = rpc.request("node.list"); - socket.readyState = CLOSED; - socket.emit("close", 1006, Buffer.from("gateway stopped")); - - await expect(request).rejects.toThrow("Gateway socket closed (1006): gateway stopped"); - }); - - it("rejects an RPC when the websocket send callback reports failure", async () => { - const socket = new FakeWebSocket(); - const rpc = createRpc(socket); - await connect(rpc, socket); - socket.sendError = new Error("send failed"); - - await expect(rpc.request("node.list")).rejects.toThrow("send failed"); - }); -}); diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 76c8acda9dfb..3c6d99a5ba9a 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -818,10 +818,6 @@ describe("scripts/test-projects changed-target routing", () => { "test/scripts/e2e-helper-env-limits.test.ts", ], ], - [ - "scripts/e2e/channel-plugin-trust-docker.sh", - ["test/scripts/docker-build-helper.test.ts", "test/scripts/test-projects.test.ts"], - ], [ "scripts/e2e/config-reload-source-docker.sh", [ @@ -2678,9 +2674,6 @@ describe("scripts/test-projects changed-target routing", () => { "scripts/e2e/parallels/update-job-timeout.ts", "scripts/e2e/parallels/windows-smoke.ts", "scripts/e2e/parallels-windows-smoke.sh", - "scripts/e2e/lib/parallels-package/build-info-commit.mjs", - "scripts/e2e/lib/parallels-macos-common.sh", - "scripts/e2e/lib/parallels-package-common.sh", ]), ).toEqual([ { @@ -2690,7 +2683,6 @@ describe("scripts/test-projects changed-target routing", () => { "test/scripts/parallels-smoke-model.test.ts", "test/scripts/parallels-npm-update-smoke.test.ts", "test/scripts/parallels-update-job-timeout.test.ts", - "test/scripts/parallels-lib-helpers.test.ts", ], watchMode: false, }, @@ -2704,18 +2696,6 @@ describe("scripts/test-projects changed-target routing", () => { }); }); - it("routes Parallels common shell helpers through lib helper owner tests", () => { - for (const changedPath of [ - "scripts/e2e/lib/parallels-macos-common.sh", - "scripts/e2e/lib/parallels-package-common.sh", - ]) { - expect(resolveChangedTestTargetPlan([changedPath]), changedPath).toEqual({ - mode: "targets", - targets: ["test/scripts/parallels-lib-helpers.test.ts"], - }); - } - }); - it("routes MCP and cron Docker E2E script targets instead of skipping changed tests", () => { const targets = [ "scripts/e2e/mcp-channels-docker.sh",