diff --git a/scripts/e2e/bundled-plugin-install-uninstall-docker.sh b/scripts/e2e/bundled-plugin-install-uninstall-docker.sh index 660267de7aa..0467abd91f0 100755 --- a/scripts/e2e/bundled-plugin-install-uninstall-docker.sh +++ b/scripts/e2e/bundled-plugin-install-uninstall-docker.sh @@ -7,7 +7,6 @@ IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-bundled-plugin-install-uninstal docker_e2e_build_or_reuse "$IMAGE_NAME" bundled-plugin-install-uninstall OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 bundled-plugin-install-uninstall empty)" -docker_e2e_harness_mount_args DOCKER_ENV_ARGS=( -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 @@ -25,9 +24,8 @@ done echo "Running bundled plugin install/uninstall Docker E2E..." RUN_LOG="$(mktemp "${TMPDIR:-/tmp}/openclaw-bundled-plugin-install-uninstall.XXXXXX")" -if ! docker run --rm \ +if ! docker_e2e_run_with_harness \ "${DOCKER_ENV_ARGS[@]}" \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ "$IMAGE_NAME" \ bash scripts/e2e/lib/bundled-plugin-install-uninstall/sweep.sh >"$RUN_LOG" 2>&1 then diff --git a/scripts/e2e/crestodian-first-run-docker.sh b/scripts/e2e/crestodian-first-run-docker.sh index 20fc158ebe9..f2a112cba17 100644 --- a/scripts/e2e/crestodian-first-run-docker.sh +++ b/scripts/e2e/crestodian-first-run-docker.sh @@ -16,16 +16,14 @@ cleanup() { trap cleanup EXIT docker_e2e_build_or_reuse "$IMAGE_NAME" crestodian-first-run -docker_e2e_harness_mount_args OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 crestodian-first-run empty)" echo "Running in-container Crestodian first-run smoke..." # Harness files are mounted read-only; the app under test comes from /app/dist. set +e -docker run --rm \ +docker_e2e_run_with_harness \ --name "$CONTAINER_NAME" \ -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ "$IMAGE_NAME" \ bash -lc "set -euo pipefail eval \"\$(printf '%s' \"\${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}\" | base64 -d)\" diff --git a/scripts/e2e/crestodian-planner-docker.sh b/scripts/e2e/crestodian-planner-docker.sh index c518e24299f..c479c0718cc 100755 --- a/scripts/e2e/crestodian-planner-docker.sh +++ b/scripts/e2e/crestodian-planner-docker.sh @@ -16,16 +16,14 @@ cleanup() { trap cleanup EXIT docker_e2e_build_or_reuse "$IMAGE_NAME" crestodian-planner -docker_e2e_harness_mount_args OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 crestodian-planner empty)" echo "Running in-container Crestodian planner fallback smoke..." # Harness files are mounted read-only; the app under test comes from /app/dist. set +e -docker run --rm \ +docker_e2e_run_with_harness \ --name "$CONTAINER_NAME" \ -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ "$IMAGE_NAME" \ bash -lc "set -euo pipefail eval \"\$(printf '%s' \"\${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}\" | base64 -d)\" diff --git a/scripts/e2e/crestodian-rescue-docker.sh b/scripts/e2e/crestodian-rescue-docker.sh index d4de3b687ce..7ca6312a837 100755 --- a/scripts/e2e/crestodian-rescue-docker.sh +++ b/scripts/e2e/crestodian-rescue-docker.sh @@ -16,16 +16,14 @@ cleanup() { trap cleanup EXIT docker_e2e_build_or_reuse "$IMAGE_NAME" crestodian-rescue -docker_e2e_harness_mount_args OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 crestodian-rescue empty)" echo "Running in-container Crestodian rescue smoke..." # Harness files are mounted read-only; the app under test comes from /app/dist. set +e -docker run --rm \ +docker_e2e_run_with_harness \ --name "$CONTAINER_NAME" \ -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ "$IMAGE_NAME" \ bash -lc "set -euo pipefail eval \"\$(printf '%s' \"\${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}\" | base64 -d)\" diff --git a/scripts/e2e/lib/plugin-update/probe.mjs b/scripts/e2e/lib/plugin-update/probe.mjs new file mode 100644 index 00000000000..d021ed533f3 --- /dev/null +++ b/scripts/e2e/lib/plugin-update/probe.mjs @@ -0,0 +1,137 @@ +import fs from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; + +const home = os.homedir(); + +const readJson = (file) => { + try { + return JSON.parse(fs.readFileSync(file, "utf8")); + } catch { + return {}; + } +}; + +const pluginRecordSnapshot = () => { + const config = readJson(openclawPath("openclaw.json")); + const index = readJson(openclawPath("plugins", "installs.json")); + const records = index.installRecords ?? index.records ?? config.plugins?.installs ?? {}; + const record = records["lossless-claw"] ?? records["@example/lossless-claw"]; + if (!record) { + throw new Error("missing plugin install record"); + } + const { source, spec, resolvedName, resolvedVersion, resolvedSpec, integrity, shasum } = record; + return { source, spec, resolvedName, resolvedVersion, resolvedSpec, integrity, shasum }; +}; + +function legacyCompat(version) { + const match = /^(\d{4})\.(\d{1,2})\.(\d{1,2})(?:[-+].*)?/.exec(version); + const [year, month, day] = match?.slice(1, 4).map(Number) ?? []; + return ( + Boolean(match) && (year < 2026 || (year === 2026 && (month < 4 || (month === 4 && day <= 25)))) + ); +} + +function openclawPath(...parts) { + return path.join(home, ".openclaw", ...parts); +} + +function writeJson(file, value) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +function seedInstallState() { + writeJson(openclawPath("extensions", "lossless-claw", "package.json"), { + name: "@example/lossless-claw", + version: "0.9.0", + }); + writeJson(process.env.OPENCLAW_CONFIG_PATH, { plugins: {} }); + writeJson(openclawPath("plugins", "installs.json"), { + version: 1, + warning: "DO NOT EDIT. This file is generated by OpenClaw plugin registry commands.", + hostContractVersion: "docker-e2e", + compatRegistryVersion: "docker-e2e", + migrationVersion: 1, + policyHash: "docker-e2e", + generatedAtMs: 1777118400000, + installRecords: { + "lossless-claw": { + source: "npm", + spec: "@example/lossless-claw@0.9.0", + installPath: "~/.openclaw/extensions/lossless-claw", + resolvedName: "@example/lossless-claw", + resolvedVersion: "0.9.0", + resolvedSpec: "@example/lossless-claw@0.9.0", + integrity: "sha512-same", + shasum: "same", + }, + }, + plugins: [], + diagnostics: [], + }); +} + +async function waitRegistry() { + for (let attempt = 0; attempt < 50; attempt += 1) { + if (await registryHealthy()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error("Local npm metadata registry failed to start"); +} + +function registryHealthy() { + return new Promise((resolve) => { + const req = http.get("http://127.0.0.1:4873/@example%2flossless-claw", (res) => { + resolve(res.statusCode === 200); + res.resume(); + }); + req.on("error", () => resolve(false)); + req.setTimeout(200, () => { + req.destroy(); + resolve(false); + }); + }); +} + +function assertSnapshot(beforePath) { + const before = readJson(beforePath); + const after = pluginRecordSnapshot(); + if (JSON.stringify(before) !== JSON.stringify(after)) { + throw new Error( + `plugin install record changed unexpectedly: ${JSON.stringify({ before, after })}`, + ); + } +} + +function assertOutput(logPath) { + const output = fs.readFileSync(logPath, "utf8"); + const failure = output.includes("Downloading @example/lossless-claw") + ? "Unexpected npm download/reinstall path" + : !output.includes("lossless-claw is up to date (0.9.0).") + ? "Expected up-to-date output missing" + : ""; + if (failure) { + throw new Error(`${failure}\n${output}`); + } +} + +const [command, arg] = process.argv.slice(2); +const commands = { + "legacy-compat": () => console.log(legacyCompat(arg || "") ? "1" : "0"), + seed: seedInstallState, + "wait-registry": waitRegistry, + snapshot: () => process.stdout.write(JSON.stringify(pluginRecordSnapshot(), null, 2)), + "assert-snapshot": () => assertSnapshot(arg), + "assert-output": () => assertOutput(arg), +}; +const run = commands[command]; +await ( + run ?? + (() => { + throw new Error(`Unknown plugin update probe command: ${command || "(missing)"}`); + }) +)(); diff --git a/scripts/e2e/lib/plugin-update/unchanged-scenario.sh b/scripts/e2e/lib/plugin-update/unchanged-scenario.sh index 2f8c0f0c455..1f498aeed7c 100644 --- a/scripts/e2e/lib/plugin-update/unchanged-scenario.sh +++ b/scripts/e2e/lib/plugin-update/unchanged-scenario.sh @@ -8,76 +8,20 @@ openclaw_e2e_install_package /tmp/openclaw-install.log "mounted OpenClaw package package_root="$(openclaw_e2e_package_root /tmp/npm-prefix)" entry="$(openclaw_e2e_package_entrypoint "$package_root")" +probe="scripts/e2e/lib/plugin-update/probe.mjs" package_version="$(node -p "require('$package_root/package.json').version")" -OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT="$( - PACKAGE_VERSION="$package_version" node -e 'const version = process.env.PACKAGE_VERSION || ""; const match = new RegExp("^(\\d{4})\\.(\\d{1,2})\\.(\\d{1,2})(?:[-+].*)?").exec(version); if (!match) { console.log("0"); process.exit(0); } const value = [Number(match[1]), Number(match[2]), Number(match[3])]; const max = [2026, 4, 25]; for (let i = 0; i < value.length; i += 1) { if (value[i] < max[i]) { console.log("1"); process.exit(0); } if (value[i] > max[i]) { console.log("0"); process.exit(0); } } console.log("1");' -)" +OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT="$(node "$probe" legacy-compat "$package_version")" export OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT export NPM_CONFIG_REGISTRY=http://127.0.0.1:4873 export PATH="/tmp/npm-prefix/bin:$PATH" -mkdir -p "$HOME/.openclaw/extensions/lossless-claw" -cat > "$HOME/.openclaw/extensions/lossless-claw/package.json" <<'JSON' -{ - "name": "@example/lossless-claw", - "version": "0.9.0" -} -JSON -cat > "$OPENCLAW_CONFIG_PATH" <<'JSON' -{ - "plugins": {} -} -JSON -mkdir -p "$HOME/.openclaw/plugins" -cat > "$HOME/.openclaw/plugins/installs.json" <<'JSON' -{ - "version": 1, - "warning": "DO NOT EDIT. This file is generated by OpenClaw plugin registry commands.", - "hostContractVersion": "docker-e2e", - "compatRegistryVersion": "docker-e2e", - "migrationVersion": 1, - "policyHash": "docker-e2e", - "generatedAtMs": 1777118400000, - "installRecords": { - "lossless-claw": { - "source": "npm", - "spec": "@example/lossless-claw@0.9.0", - "installPath": "~/.openclaw/extensions/lossless-claw", - "resolvedName": "@example/lossless-claw", - "resolvedVersion": "0.9.0", - "resolvedSpec": "@example/lossless-claw@0.9.0", - "integrity": "sha512-same", - "shasum": "same" - } - }, - "plugins": [], - "diagnostics": [] -} -JSON +node "$probe" seed node scripts/e2e/lib/plugin-update/registry-server.mjs >/tmp/openclaw-e2e-registry.log 2>&1 & registry_pid=$! trap 'kill "$registry_pid" >/dev/null 2>&1 || true' EXIT -registry_ready=0 -for _ in $(seq 1 50); do - if node --input-type=module -e ' - import http from "node:http"; - const req = http.get("http://127.0.0.1:4873/@example%2flossless-claw", (res) => { - process.exit(res.statusCode === 200 ? 0 : 1); - }); - req.on("error", () => process.exit(1)); - req.setTimeout(200, () => { - req.destroy(); - process.exit(1); - }); - '; then - registry_ready=1 - break - fi - sleep 0.1 -done -if [ "$registry_ready" -ne 1 ]; then +if ! node "$probe" wait-registry; then echo "Local npm metadata registry failed to start" cat /tmp/openclaw-e2e-registry.log || true exit 1 @@ -89,37 +33,7 @@ if [ "$OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT" != "1" ]; then fi plugin_update_timeout_seconds="${OPENCLAW_PLUGIN_UPDATE_TIMEOUT_SECONDS:-180}" -node --input-type=module > /tmp/plugin-update-before.json <<'NODE' -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -const readJson = (file) => { - try { - return JSON.parse(fs.readFileSync(file, "utf8")); - } catch { - return {}; - } -}; -const home = os.homedir(); -const config = readJson(path.join(home, ".openclaw", "openclaw.json")); -const index = readJson(path.join(home, ".openclaw", "plugins", "installs.json")); -const records = index.installRecords ?? index.records ?? config.plugins?.installs ?? {}; -const record = records["lossless-claw"] ?? records["@example/lossless-claw"]; -if (!record) { - throw new Error("missing seeded plugin install record"); -} -const snapshot = { - source: record.source, - spec: record.spec, - resolvedName: record.resolvedName, - resolvedVersion: record.resolvedVersion, - resolvedSpec: record.resolvedSpec, - integrity: record.integrity, - shasum: record.shasum, -}; -process.stdout.write(JSON.stringify(snapshot, null, 2)); -NODE +node "$probe" snapshot > /tmp/plugin-update-before.json set +e timeout "${plugin_update_timeout_seconds}s" node "$entry" plugins update @example/lossless-claw > /tmp/plugin-update-output.log 2>&1 @@ -143,48 +57,6 @@ if [ -n "$before_config_hash" ]; then fi fi -node --input-type=module <<'NODE' -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -const readJson = (file) => { - try { - return JSON.parse(fs.readFileSync(file, "utf8")); - } catch { - return {}; - } -}; -const home = os.homedir(); -const before = readJson("/tmp/plugin-update-before.json"); -const config = readJson(path.join(home, ".openclaw", "openclaw.json")); -const index = readJson(path.join(home, ".openclaw", "plugins", "installs.json")); -const records = index.installRecords ?? index.records ?? config.plugins?.installs ?? {}; -const record = records["lossless-claw"] ?? records["@example/lossless-claw"]; -if (!record) { - throw new Error("missing plugin install record after update"); -} -const after = { - source: record.source, - spec: record.spec, - resolvedName: record.resolvedName, - resolvedVersion: record.resolvedVersion, - resolvedSpec: record.resolvedSpec, - integrity: record.integrity, - shasum: record.shasum, -}; -if (JSON.stringify(before) !== JSON.stringify(after)) { - throw new Error(`plugin install record changed unexpectedly: ${JSON.stringify({ before, after })}`); -} -NODE -if grep -q "Downloading @example/lossless-claw" /tmp/plugin-update-output.log; then - echo "Unexpected npm download/reinstall path" - cat /tmp/plugin-update-output.log - exit 1 -fi -if ! grep -q "lossless-claw is up to date (0.9.0)." /tmp/plugin-update-output.log; then - echo "Expected up-to-date output missing" - cat /tmp/plugin-update-output.log - exit 1 -fi +node "$probe" assert-snapshot /tmp/plugin-update-before.json +node "$probe" assert-output /tmp/plugin-update-output.log cat /tmp/plugin-update-output.log diff --git a/scripts/e2e/npm-onboard-channel-agent-docker.sh b/scripts/e2e/npm-onboard-channel-agent-docker.sh index 0d87f53bd00..c816ce11bd2 100644 --- a/scripts/e2e/npm-onboard-channel-agent-docker.sh +++ b/scripts/e2e/npm-onboard-channel-agent-docker.sh @@ -38,17 +38,15 @@ prepare_package_tgz() { prepare_package_tgz docker_e2e_package_mount_args "$PACKAGE_TGZ" -docker_e2e_harness_mount_args run_log="$(docker_e2e_run_log npm-onboard-channel-agent)" OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 npm-onboard-channel-agent empty)" echo "Running npm tarball onboard/channel/agent Docker E2E ($CHANNEL)..." -if ! docker run --rm \ +if ! docker_e2e_run_with_harness \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ -e OPENCLAW_NPM_ONBOARD_CHANNEL="$CHANNEL" \ -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \ "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ -i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF' set -euo pipefail diff --git a/scripts/e2e/pi-bundle-mcp-tools-docker.sh b/scripts/e2e/pi-bundle-mcp-tools-docker.sh index f5bcb45b43e..bda39081537 100755 --- a/scripts/e2e/pi-bundle-mcp-tools-docker.sh +++ b/scripts/e2e/pi-bundle-mcp-tools-docker.sh @@ -16,16 +16,14 @@ cleanup() { trap cleanup EXIT docker_e2e_build_or_reuse "$IMAGE_NAME" pi-bundle-mcp-tools -docker_e2e_harness_mount_args OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 pi-bundle-mcp-tools empty)" echo "Running in-container Pi bundle MCP tool availability smoke..." # Harness files are mounted read-only; the app under test comes from /app/dist. set +e -docker run --rm \ +docker_e2e_run_with_harness \ --name "$CONTAINER_NAME" \ -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ "$IMAGE_NAME" \ bash -lc "set -euo pipefail eval \"\$(printf '%s' \"\${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}\" | base64 -d)\" diff --git a/scripts/e2e/plugin-update-unchanged-docker.sh b/scripts/e2e/plugin-update-unchanged-docker.sh index 386bef49a20..9dec2ed4475 100755 --- a/scripts/e2e/plugin-update-unchanged-docker.sh +++ b/scripts/e2e/plugin-update-unchanged-docker.sh @@ -12,19 +12,17 @@ SKIP_BUILD="${OPENCLAW_PLUGIN_UPDATE_E2E_SKIP_BUILD:-0}" PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz plugin-update "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}")" # Bare lanes mount the package artifact instead of baking app sources into the image. docker_e2e_package_mount_args "$PACKAGE_TGZ" -docker_e2e_harness_mount_args docker_e2e_build_or_reuse "$IMAGE_NAME" plugin-update "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "bare" "$SKIP_BUILD" OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 plugin-update empty)" echo "Running unchanged plugin update smoke..." -docker run --rm \ +docker_e2e_run_with_harness \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ -e OPENCLAW_SKIP_CHANNELS=1 \ -e OPENCLAW_SKIP_PROVIDERS=1 \ -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \ "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ "$IMAGE_NAME" \ bash scripts/e2e/lib/plugin-update/unchanged-scenario.sh diff --git a/scripts/e2e/session-runtime-context-docker.sh b/scripts/e2e/session-runtime-context-docker.sh index fb0e9eec10a..d0f68cc2129 100644 --- a/scripts/e2e/session-runtime-context-docker.sh +++ b/scripts/e2e/session-runtime-context-docker.sh @@ -17,15 +17,13 @@ cleanup() { trap cleanup EXIT docker_e2e_build_or_reuse "$IMAGE_NAME" session-runtime-context -docker_e2e_harness_mount_args echo "Running session runtime context Docker E2E..." # Harness files are mounted read-only; the app under test comes from /app/dist. set +e -docker run --rm \ +docker_e2e_run_with_harness \ --name "$CONTAINER_NAME" \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ "$IMAGE_NAME" \ bash -lc 'set -euo pipefail; tsx scripts/e2e/session-runtime-context-docker-client.ts' \ >"$RUN_LOG" 2>&1 diff --git a/scripts/lib/docker-e2e-package.sh b/scripts/lib/docker-e2e-package.sh index 6f699d31b29..c899462bb72 100644 --- a/scripts/lib/docker-e2e-package.sh +++ b/scripts/lib/docker-e2e-package.sh @@ -62,3 +62,8 @@ docker_e2e_package_mount_args() { docker_e2e_harness_mount_args() { DOCKER_E2E_HARNESS_ARGS=(-v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro" -v "$ROOT_DIR/scripts/lib:/app/scripts/lib:ro") } + +docker_e2e_run_with_harness() { + docker_e2e_harness_mount_args + docker run --rm "${DOCKER_E2E_HARNESS_ARGS[@]}" "$@" +}