diff --git a/config/knip.config.ts b/config/knip.config.ts index 06d1dfe6b9ee..0e5c51cabaf1 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -63,6 +63,7 @@ const repositoryScriptEntries = [ "scripts/e2e/lib/release-user-journey/write-clickclack-plugin.mjs!", "scripts/e2e/lib/run-with-pty.mjs!", "scripts/e2e/lib/sandbox-browser-sidecar/scenario.mjs!", + "scripts/e2e/lib/upgrade-survivor/config-parking.mjs!", "scripts/e2e/lib/upgrade-survivor/probe-gateway.mjs!", "scripts/embedded-run-abort-leak.ts!", "scripts/fixtures/packed-plugin-sdk-type-smoke.ts!", diff --git a/scripts/e2e/lib/clawhub-fixture-server.cjs b/scripts/e2e/lib/clawhub-fixture-server.cjs index 1633d85cbe78..486ffa1b14f0 100644 --- a/scripts/e2e/lib/clawhub-fixture-server.cjs +++ b/scripts/e2e/lib/clawhub-fixture-server.cjs @@ -64,73 +64,6 @@ async function assertNoRequests(baseUrl) { } } -function parkPrepublishAuthoredConfig(configPath, snapshotPath) { - if (!configPath || !snapshotPath) { - throw new Error("park-prepublish-auth-config requires "); - } - const authoredConfig = fs.readFileSync(configPath); - const config = JSON.parse(authoredConfig.toString("utf8")); - if (!config || typeof config !== "object" || Array.isArray(config)) { - throw new Error("prepublish auth config must be a JSON object"); - } - for (const key of ["plugins", "channels", "gateway"]) { - const value = config[key]; - if (value !== undefined && (!value || typeof value !== "object" || Array.isArray(value))) { - throw new Error(`prepublish auth config ${key} must be an object`); - } - } - if (config.plugins?.allow !== undefined && !Array.isArray(config.plugins.allow)) { - throw new Error("prepublish auth config plugins.allow must be an array"); - } - if ( - config.plugins?.entries !== undefined && - (!config.plugins.entries || - typeof config.plugins.entries !== "object" || - Array.isArray(config.plugins.entries)) - ) { - throw new Error("prepublish auth config plugins.entries must be an object"); - } - if ( - config.gateway?.reload !== undefined && - (!config.gateway.reload || - typeof config.gateway.reload !== "object" || - Array.isArray(config.gateway.reload)) - ) { - throw new Error("prepublish auth config gateway.reload must be an object"); - } - if (Array.isArray(config.plugins?.allow)) { - config.plugins.allow = config.plugins.allow.filter((id) => id !== "whatsapp"); - } - if (config.plugins?.entries && typeof config.plugins.entries === "object") { - delete config.plugins.entries.whatsapp; - } - if (config.channels && typeof config.channels === "object") { - delete config.channels.whatsapp; - } - config.gateway ??= {}; - config.gateway.reload = { ...config.gateway.reload, mode: "off" }; - fs.writeFileSync(snapshotPath, authoredConfig, { mode: 0o600 }); - replaceFileAtomically(configPath, Buffer.from(`${JSON.stringify(config, null, 2)}\n`)); -} - -function restorePrepublishAuthoredConfig(configPath, snapshotPath) { - if (!configPath || !snapshotPath) { - throw new Error("restore-prepublish-auth-config requires "); - } - replaceFileAtomically(configPath, fs.readFileSync(snapshotPath)); -} - -function replaceFileAtomically(filePath, contents) { - const tempPath = `${filePath}.tmp.${process.pid}`; - const mode = fs.statSync(filePath).mode; - try { - fs.writeFileSync(tempPath, contents, { mode }); - fs.renameSync(tempPath, filePath); - } finally { - fs.rmSync(tempPath, { force: true }); - } -} - function startPrepublishArtifactServer() { const manifest = JSON.parse(fs.readFileSync(artifactManifestFile, "utf8")); if (!Array.isArray(manifest.packages) || manifest.packages.length === 0) { @@ -731,16 +664,6 @@ if (profile === "assert-no-requests") { return; } -if (profile === "park-prepublish-auth-config") { - parkPrepublishAuthoredConfig(portFile, artifactManifestFile); - return; -} - -if (profile === "restore-prepublish-auth-config") { - restorePrepublishAuthoredConfig(portFile, artifactManifestFile); - return; -} - const fixture = profiles[profile]; if (!fixture || !portFile) { if (profile === "prepublish-artifacts" && portFile && artifactManifestFile) { diff --git a/scripts/e2e/lib/upgrade-survivor/assertions.mjs b/scripts/e2e/lib/upgrade-survivor/assertions.mjs index 732a5c69adbd..179e8ed3066d 100644 --- a/scripts/e2e/lib/upgrade-survivor/assertions.mjs +++ b/scripts/e2e/lib/upgrade-survivor/assertions.mjs @@ -1,4 +1,5 @@ import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; // Assertions for upgrade-survivor E2E scenarios. import fs from "node:fs"; import path from "node:path"; @@ -36,6 +37,18 @@ const PERSONA_FILES = new Map([ const LEGACY_SESSION_MAIN_ID = "upgrade-main-session"; const LEGACY_SESSION_DIRECT_ID = "upgrade-direct-session"; const LEGACY_SESSION_GROUP_ID = "upgrade-group-session"; +const PLUGIN_DECLARED_SURFACE_GROUPS = [ + "channels", + "providers", + "tools", + "contracts", + "hooks", + "mcpServers", + "cliCommands", + "cliBackends", + "skills", + "dangerousConfigFlags", +]; function requireEnv(name) { const value = process.env[name]; @@ -469,8 +482,14 @@ function assertConfigSurvived() { if (acceptsIntent(coverage, "discord-channel")) { const discord = config.channels?.discord; assert(discord?.enabled === true, "discord enabled flag changed"); - const discordAllowFrom = discord.allowFrom ?? discord.dm?.allowFrom; - const discordDmPolicy = discord.dmPolicy ?? discord.dm?.policy; + const stage = process.env.OPENCLAW_UPGRADE_SURVIVOR_ASSERT_STAGE || "survival"; + const discordAllowFrom = + stage === "baseline" ? (discord.allowFrom ?? discord.dm?.allowFrom) : discord.allowFrom; + const discordDmPolicy = + stage === "baseline" ? (discord.dmPolicy ?? discord.dm?.policy) : discord.dmPolicy; + if (stage !== "baseline") { + assert(!Object.hasOwn(discord, "dm"), "legacy Discord DM config survived update"); + } assert(discordDmPolicy === "allowlist", "discord DM policy changed"); assert( Array.isArray(discordAllowFrom) && discordAllowFrom.includes("111111111111111111"), @@ -1000,7 +1019,7 @@ function assertExternalPluginInstall(records, pluginId, packageName) { String(record.spec ?? record.resolvedSpec ?? "").startsWith(packageName), `configured external ${pluginId} plugin npm spec changed`, ); - return; + return packageJson; } assert( record.clawhubPackage === packageName, @@ -1011,6 +1030,73 @@ function assertExternalPluginInstall(records, pluginId, packageName) { isPathInside(extensionsRoot, installPath), `configured external ${pluginId} ClawHub install path outside managed extensions root: ${installPath}`, ); + return packageJson; +} + +function pluginInstallIntegrity(record) { + return record.integrity ?? record.npmIntegrity ?? record.clawpackSha256 ?? record.gitCommit; +} + +function acceptedSurfaceHash(surface) { + const canonical = Object.fromEntries( + PLUGIN_DECLARED_SURFACE_GROUPS.map((group) => [group, surface[group].toSorted()]), + ); + return createHash("sha256").update(JSON.stringify(canonical)).digest("hex"); +} + +function assertCompanionPluginConsent(record, pluginId) { + const integrity = pluginInstallIntegrity(record); + assert( + typeof integrity === "string" && integrity.length > 0, + `${pluginId} plugin integrity missing`, + ); + assert( + record.acceptedSurface && typeof record.acceptedSurface === "object", + `${pluginId} plugin accepted surface missing`, + ); + for (const group of PLUGIN_DECLARED_SURFACE_GROUPS) { + assert( + Array.isArray(record.acceptedSurface[group]), + `${pluginId} plugin accepted surface ${group} missing`, + ); + } + assert( + record.acceptedSurfaceHash === acceptedSurfaceHash(record.acceptedSurface), + `${pluginId} plugin consent hash changed`, + ); + assert( + record.acceptedSurfaceIntegrity === integrity, + `${pluginId} plugin consent integrity changed`, + ); + assert( + typeof record.acceptedSurfaceAt === "string" && + Number.isFinite(Date.parse(record.acceptedSurfaceAt)), + `${pluginId} plugin consent timestamp missing`, + ); +} + +function assertCompanionPluginInstalls([expectedVersion]) { + assert(expectedVersion, "assert-companion-installs requires "); + const records = readInstalledPluginIndex().installRecords ?? {}; + for (const [pluginId, packageName, source] of [ + ["discord", "@openclaw/discord", "npm"], + ["whatsapp", "@openclaw/whatsapp", "clawhub"], + ["codex", "@openclaw/codex", "npm"], + ]) { + const packageJson = assertExternalPluginInstall(records, pluginId, packageName); + const record = records[pluginId]; + assert(record.source === source, `${pluginId} plugin source changed: ${record.source}`); + const installedVersion = source === "clawhub" ? record.version : record.resolvedVersion; + assert( + installedVersion === expectedVersion, + `${pluginId} plugin version changed: ${String(installedVersion)}`, + ); + assert( + packageJson.version === expectedVersion, + `${pluginId} installed package version changed: ${String(packageJson.version)}`, + ); + assertCompanionPluginConsent(record, pluginId); + } } function assertConfiguredPluginInstalls() { @@ -1229,6 +1315,8 @@ if (command === "list-scenarios") { } else if (command === "assert-state") { assertStateSurvived(); assertConfiguredPluginInstalls(); +} else if (command === "assert-companion-installs") { + assertCompanionPluginInstalls(process.argv.slice(3)); } else if (command === "assert-status-json") { assertStatusJson(process.argv.slice(3)); } else if (command === "assert-update-run-self-upgrade") { diff --git a/scripts/e2e/lib/upgrade-survivor/config-parking.mjs b/scripts/e2e/lib/upgrade-survivor/config-parking.mjs new file mode 100644 index 000000000000..4cc87db0e24a --- /dev/null +++ b/scripts/e2e/lib/upgrade-survivor/config-parking.mjs @@ -0,0 +1,142 @@ +#!/usr/bin/env node + +import fs from "node:fs"; + +function requireObject(value, label) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } +} + +function requirePaths(command, configPath, snapshotPath) { + if (!configPath || !snapshotPath) { + throw new Error(`${command} requires `); + } +} + +function replaceFileAtomically(filePath, contents) { + const tempPath = `${filePath}.tmp.${process.pid}`; + const mode = fs.statSync(filePath).mode; + try { + fs.writeFileSync(tempPath, contents, { mode }); + fs.renameSync(tempPath, filePath); + } finally { + fs.rmSync(tempPath, { force: true }); + } +} + +function snapshotAndReplace(configPath, snapshotPath, authoredConfig, parkedConfig) { + fs.writeFileSync(snapshotPath, authoredConfig, { mode: 0o600 }); + fs.chmodSync(snapshotPath, 0o600); + replaceFileAtomically( + configPath, + Buffer.from(`${JSON.stringify(parkedConfig, null, 2)}\n`, "utf8"), + ); +} + +function parkPrepublish(configPath, snapshotPath) { + requirePaths("park-prepublish", configPath, snapshotPath); + const authoredConfig = fs.readFileSync(configPath); + const config = JSON.parse(authoredConfig.toString("utf8")); + requireObject(config, "prepublish auth config"); + for (const key of ["plugins", "channels", "gateway"]) { + const value = config[key]; + if (value !== undefined) { + requireObject(value, `prepublish auth config ${key}`); + } + } + if (config.plugins?.allow !== undefined && !Array.isArray(config.plugins.allow)) { + throw new Error("prepublish auth config plugins.allow must be an array"); + } + if (config.plugins?.entries !== undefined) { + requireObject(config.plugins.entries, "prepublish auth config plugins.entries"); + } + if (config.gateway?.reload !== undefined) { + requireObject(config.gateway.reload, "prepublish auth config gateway.reload"); + } + if (Array.isArray(config.plugins?.allow)) { + config.plugins.allow = config.plugins.allow.filter((id) => id !== "whatsapp"); + } + if (config.plugins?.entries) { + delete config.plugins.entries.whatsapp; + } + if (config.channels) { + delete config.channels.whatsapp; + } + config.gateway ??= {}; + config.gateway.reload = { ...config.gateway.reload, mode: "off" }; + snapshotAndReplace(configPath, snapshotPath, authoredConfig, config); +} + +function parkRestartProbe(configPath, snapshotPath, rawPort) { + requirePaths("park-restart-probe", configPath, snapshotPath); + const port = Number(rawPort); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error("park-restart-probe requires a valid port"); + } + const authoredConfig = fs.readFileSync(configPath); + requireObject(JSON.parse(authoredConfig.toString("utf8")), "restart probe config"); + snapshotAndReplace(configPath, snapshotPath, authoredConfig, { + plugins: { enabled: false }, + gateway: { + port, + mode: "local", + bind: "loopback", + controlUi: { enabled: false }, + auth: { + mode: "token", + token: { + source: "env", + provider: "default", + id: "GATEWAY_AUTH_TOKEN_REF", + }, + }, + reload: { mode: "off" }, + }, + }); +} + +function parkCompanionInstall(configPath, snapshotPath) { + requirePaths("park-companion-install", configPath, snapshotPath); + const authoredConfig = fs.readFileSync(configPath); + requireObject(JSON.parse(authoredConfig.toString("utf8")), "companion install config"); + snapshotAndReplace(configPath, snapshotPath, authoredConfig, { + plugins: { enabled: false }, + }); +} + +function restore(configPath, snapshotPath) { + requirePaths("restore", configPath, snapshotPath); + const authoredConfig = fs.readFileSync(snapshotPath); + replaceFileAtomically(configPath, authoredConfig); + if (!fs.readFileSync(configPath).equals(authoredConfig)) { + throw new Error("restored config did not match authored bytes"); + } + fs.rmSync(snapshotPath); +} + +const [command, configPath, snapshotPath, port] = process.argv.slice(2); + +try { + switch (command) { + case "park-prepublish": + parkPrepublish(configPath, snapshotPath); + break; + case "park-restart-probe": + parkRestartProbe(configPath, snapshotPath, port); + break; + case "park-companion-install": + parkCompanionInstall(configPath, snapshotPath); + break; + case "restore": + restore(configPath, snapshotPath); + break; + default: + throw new Error( + "usage: config-parking.mjs [port]", + ); + } +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +} diff --git a/scripts/e2e/lib/upgrade-survivor/run.sh b/scripts/e2e/lib/upgrade-survivor/run.sh index 9f5107891710..1c446b50ae4a 100644 --- a/scripts/e2e/lib/upgrade-survivor/run.sh +++ b/scripts/e2e/lib/upgrade-survivor/run.sh @@ -462,8 +462,8 @@ prepublish_auto_auth_enabled() { park_prepublish_authored_config() { prepublish_auto_auth_enabled || return 0 - node "${OPENCLAW_UPGRADE_SURVIVOR_CLAWHUB_FIXTURE_SERVER:-scripts/e2e/lib/clawhub-fixture-server.cjs}" \ - park-prepublish-auth-config "$OPENCLAW_CONFIG_PATH" "$PREPUBLISH_AUTHORED_CONFIG" + node "${OPENCLAW_UPGRADE_SURVIVOR_CONFIG_PARKING_HELPER:-scripts/e2e/lib/upgrade-survivor/config-parking.mjs}" \ + park-prepublish "$OPENCLAW_CONFIG_PATH" "$PREPUBLISH_AUTHORED_CONFIG" } assert_prepublish_fixture_idle() { @@ -474,15 +474,8 @@ assert_prepublish_fixture_idle() { restore_prepublish_authored_config() { prepublish_auto_auth_enabled || return 0 - if ! node "${OPENCLAW_UPGRADE_SURVIVOR_CLAWHUB_FIXTURE_SERVER:-scripts/e2e/lib/clawhub-fixture-server.cjs}" \ - restore-prepublish-auth-config "$OPENCLAW_CONFIG_PATH" "$PREPUBLISH_AUTHORED_CONFIG"; then - return 1 - fi - if ! cmp -s "$PREPUBLISH_AUTHORED_CONFIG" "$OPENCLAW_CONFIG_PATH"; then - echo "restored prepublish config did not match authored bytes" >&2 - return 1 - fi - rm -f "$PREPUBLISH_AUTHORED_CONFIG" + node "${OPENCLAW_UPGRADE_SURVIVOR_CONFIG_PARKING_HELPER:-scripts/e2e/lib/upgrade-survivor/config-parking.mjs}" \ + restore "$OPENCLAW_CONFIG_PATH" "$PREPUBLISH_AUTHORED_CONFIG" } configure_plugin_registry() { diff --git a/scripts/e2e/lib/upgrade-survivor/update-restart-auth.sh b/scripts/e2e/lib/upgrade-survivor/update-restart-auth.sh index 6313a5e33920..75e5609af6a5 100644 --- a/scripts/e2e/lib/upgrade-survivor/update-restart-auth.sh +++ b/scripts/e2e/lib/upgrade-survivor/update-restart-auth.sh @@ -413,29 +413,85 @@ prepare_update_restart_probe_current_install() { local log_file="$2" local command_timeout="${OPENCLAW_UPGRADE_SURVIVOR_COMMAND_TIMEOUT:-900s}" local doctor_log="${log_file}.doctor" + local authored_config="${log_file}.authored-config" + local parking_helper="${OPENCLAW_UPGRADE_SURVIVOR_CONFIG_PARKING_HELPER:-scripts/e2e/lib/upgrade-survivor/config-parking.mjs}" + local failure_stage="" + local probe_status=0 + local restore_status=0 local start_epoch local ready_epoch echo "Preparing candidate-auth gateway for automatic update restart." install_update_restart_systemctl_shim seed_update_restart_probe_device_auth - if ! openclaw_e2e_maybe_timeout "$command_timeout" openclaw doctor --fix --non-interactive >"$doctor_log" 2>&1; then + # Service installation persists OPENCLAW_CONFIG_PATH, so isolate the canonical file in place. + # Reload stays off through service setup; restoring authored bytes cannot restart this probe. + node "$parking_helper" \ + park-restart-probe "$OPENCLAW_CONFIG_PATH" "$authored_config" "$port" || probe_status=$? + if [ "$probe_status" -ne 0 ]; then + echo "failed to park authored config for candidate restart probe" >&2 + if [ -e "$authored_config" ]; then + node "$parking_helper" restore "$OPENCLAW_CONFIG_PATH" "$authored_config" || + restore_status=$? + fi + if [ "$restore_status" -ne 0 ]; then + return "$restore_status" + fi + return "$probe_status" + fi + # This setup pass migrates candidate device identity while deferring plugin convergence. + # Parent-write support lets that migration persist before the real update begins. + openclaw_e2e_maybe_timeout \ + "$command_timeout" \ + env \ + OPENCLAW_UPDATE_IN_PROGRESS=1 \ + OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR=1 \ + OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE=1 \ + openclaw doctor --fix --non-interactive >"$doctor_log" 2>&1 || { + probe_status=$? + failure_stage="doctor" + } + if [ "$probe_status" -ne 0 ]; then echo "candidate device identity migration failed" >&2 cat "$doctor_log" >&2 || true - return 1 fi - start_epoch="$(node -e "process.stdout.write(String(Date.now()))")" - env -u OPENCLAW_GATEWAY_TOKEN -u OPENCLAW_GATEWAY_PASSWORD openclaw gateway --port "$port" --bind loopback --allow-unconfigured >"$log_file" 2>&1 & - gateway_pid="$!" - printf '%s\n' "$gateway_pid" >"$OPENCLAW_UPGRADE_SURVIVOR_SYSTEMCTL_SHIM_PID_FILE" - openclaw_e2e_wait_gateway_ready "$gateway_pid" "$log_file" 360 "$port" - ready_epoch="$(node -e "process.stdout.write(String(Date.now()))")" - start_seconds=$(((ready_epoch - start_epoch + 999) / 1000)) - write_update_restart_service_auth_env - if ! openclaw_e2e_maybe_timeout "$command_timeout" env -u OPENCLAW_GATEWAY_TOKEN -u OPENCLAW_GATEWAY_PASSWORD openclaw gateway install --force --json >"$OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SERVICE_INSTALL_JSON" 2>"$OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SERVICE_INSTALL_ERR"; then + if [ "$probe_status" -eq 0 ]; then + start_epoch="$(node -e "process.stdout.write(String(Date.now()))")" + env -u OPENCLAW_GATEWAY_TOKEN -u OPENCLAW_GATEWAY_PASSWORD openclaw gateway --port "$port" --bind loopback --allow-unconfigured >"$log_file" 2>&1 & + gateway_pid="$!" + printf '%s\n' "$gateway_pid" >"$OPENCLAW_UPGRADE_SURVIVOR_SYSTEMCTL_SHIM_PID_FILE" + openclaw_e2e_wait_gateway_ready "$gateway_pid" "$log_file" 360 "$port" || { + probe_status=$? + failure_stage="readiness" + } + fi + if [ "$probe_status" -eq 0 ]; then + ready_epoch="$(node -e "process.stdout.write(String(Date.now()))")" + start_seconds=$(((ready_epoch - start_epoch + 999) / 1000)) + write_update_restart_service_auth_env || { + probe_status=$? + failure_stage="service-env" + } + fi + if [ "$probe_status" -eq 0 ]; then + openclaw_e2e_maybe_timeout "$command_timeout" env -u OPENCLAW_GATEWAY_TOKEN -u OPENCLAW_GATEWAY_PASSWORD openclaw gateway install --force --json >"$OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SERVICE_INSTALL_JSON" 2>"$OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SERVICE_INSTALL_ERR" || { + probe_status=$? + failure_stage="install" + } + fi + if [ "$failure_stage" = "install" ]; then echo "gateway service install failed" >&2 cat "$OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SERVICE_INSTALL_ERR" >&2 || true cat "$OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SERVICE_INSTALL_JSON" >&2 || true - return 1 + elif [ "$failure_stage" = "readiness" ]; then + echo "candidate restart probe gateway did not become ready" >&2 + elif [ "$failure_stage" = "service-env" ]; then + echo "failed to write candidate restart service environment" >&2 fi + node "$parking_helper" restore "$OPENCLAW_CONFIG_PATH" "$authored_config" || restore_status=$? + if [ "$restore_status" -ne 0 ]; then + echo "failed to restore authored config after candidate restart probe" >&2 + return "$restore_status" + fi + return "$probe_status" } diff --git a/scripts/e2e/upgrade-survivor-docker.sh b/scripts/e2e/upgrade-survivor-docker.sh index 62fb1a685054..7737d8da1c3d 100755 --- a/scripts/e2e/upgrade-survivor-docker.sh +++ b/scripts/e2e/upgrade-survivor-docker.sh @@ -10,6 +10,7 @@ DOCKER_E2E_HARNESS_ROOT_DIR="$HARNESS_ROOT_DIR" source "$HARNESS_ROOT_DIR/scripts/lib/docker-e2e-image.sh" source "$HARNESS_ROOT_DIR/scripts/lib/docker-e2e-package.sh" source "$HARNESS_ROOT_DIR/scripts/lib/openclaw-e2e-instance.sh" +source "$HARNESS_ROOT_DIR/scripts/e2e/lib/prepublish-plugin-registry.sh" IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-upgrade-survivor-e2e" OPENCLAW_UPGRADE_SURVIVOR_E2E_IMAGE)" SKIP_BUILD="${OPENCLAW_UPGRADE_SURVIVOR_E2E_SKIP_BUILD:-0}" @@ -91,7 +92,7 @@ LANE_ARTIFACT_SUFFIX="$(resolve_lane_artifact_suffix)" LANE_ARTIFACT_SUFFIX="${LANE_ARTIFACT_SUFFIX//[^A-Za-z0-9_.-]/_}" ARTIFACT_DIR="${OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_DIR:-$ROOT_DIR/.artifacts/upgrade-survivor/$LANE_ARTIFACT_SUFFIX}" DOCKER_RUN_USER_ARGS=() -PREPUBLISH_PLUGIN_REGISTRY_ARGS=() +OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DOCKER_ARGS=() AUTO_PREPUBLISH_PLUGIN_REGISTRY_ROOT="" PROBE_ENV_ARGS=( -e OPENCLAW_UPGRADE_SURVIVOR_PROBE_TIMEOUT_MS="$PROBE_TIMEOUT_MS" @@ -108,34 +109,9 @@ if [ -n "${OPENCLAW_UPGRADE_SURVIVOR_READYZ_ALLOW_DEGRADED:-}" ]; then -e OPENCLAW_UPGRADE_SURVIVOR_READYZ_ALLOW_DEGRADED="$OPENCLAW_UPGRADE_SURVIVOR_READYZ_ALLOW_DEGRADED" ) fi -configure_prepublish_plugin_registry() { - local registry_dir="$1" - PREPUBLISH_PLUGIN_REGISTRY_DIR="$( - cd "$registry_dir" && pwd - )" - local manifest="$PREPUBLISH_PLUGIN_REGISTRY_DIR/prepublish-plugin-registry.json" - if [ ! -f "$manifest" ]; then - echo "Prepublish plugin registry manifest is missing." >&2 - exit 1 - fi - local source_sha="${OPENCLAW_DOCKER_E2E_SELECTED_SHA:-}" - local candidate_version="${OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_CANDIDATE_VERSION:-}" - local manifest_sha256="${OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_MANIFEST_SHA256:-}" - source_sha="${source_sha:-$(node -e 'process.stdout.write(require(process.argv[1]).sourceSha)' "$manifest")}" - candidate_version="${candidate_version:-$(node -e 'process.stdout.write(require(process.argv[1]).candidateVersion)' "$manifest")}" - if [ -z "$manifest_sha256" ]; then - manifest_sha256="$(node -e 'const fs=require("node:fs"),crypto=require("node:crypto");process.stdout.write(crypto.createHash("sha256").update(fs.readFileSync(process.argv[1])).digest("hex"))' "$manifest")" - fi - PREPUBLISH_PLUGIN_REGISTRY_ARGS=( - -e OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR=/tmp/openclaw-prepublish-plugin-registry - -e OPENCLAW_DOCKER_E2E_SELECTED_SHA="$source_sha" - -e OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_CANDIDATE_VERSION="$candidate_version" - -e OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_MANIFEST_SHA256="$manifest_sha256" - -v "$PREPUBLISH_PLUGIN_REGISTRY_DIR:/tmp/openclaw-prepublish-plugin-registry:ro" - ) -} if [ -n "${OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR:-}" ]; then - configure_prepublish_plugin_registry "$OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR" + openclaw_prepublish_plugin_registry_configure_docker_args \ + "$OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR" fi cleanup_outer() { docker_e2e_cleanup_package_tgz "${PACKAGE_TGZ:-}" @@ -223,7 +199,7 @@ if [ "${OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE:-0}" = "1" ]; then OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS="$BASELINE_SPEC" \ OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS="$SCENARIO" \ node "$HARNESS_ROOT_DIR/scripts/test-docker-all.mjs" --prepare-plugin-registry - configure_prepublish_plugin_registry \ + openclaw_prepublish_plugin_registry_configure_docker_args \ "$AUTO_PREPUBLISH_PLUGIN_REGISTRY_ROOT/prepublish-plugin-registry" fi @@ -260,13 +236,15 @@ if [ "${OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE:-0}" = "1" ]; then -e OPENCLAW_UPGRADE_SURVIVOR_START_BUDGET_SECONDS="$START_BUDGET_SECONDS" \ -e OPENCLAW_UPGRADE_SURVIVOR_STATUS_BUDGET_SECONDS="$STATUS_BUDGET_SECONDS" \ -e OPENCLAW_UPGRADE_SURVIVOR_CLAWHUB_FIXTURE_SERVER=/tmp/openclaw-clawhub-fixture-server.cjs \ + -e OPENCLAW_UPGRADE_SURVIVOR_CONFIG_PARKING_HELPER=/tmp/openclaw-config-parking.mjs \ "${PROBE_ENV_ARGS[@]}" \ ${LIVE_OPENAI_ENV_ARGS[@]+"${LIVE_OPENAI_ENV_ARGS[@]}"} \ -v "$ARTIFACT_DIR:/tmp/openclaw-upgrade-survivor-artifacts" \ -v "$TRUSTED_TSX_NODE_MODULES:/tmp/openclaw-release-harness/node_modules:ro" \ -v "$HARNESS_ROOT_DIR/scripts/e2e/lib/clawhub-fixture-server.cjs:/tmp/openclaw-clawhub-fixture-server.cjs:ro" \ + -v "$HARNESS_ROOT_DIR/scripts/e2e/lib/upgrade-survivor/config-parking.mjs:/tmp/openclaw-config-parking.mjs:ro" \ -v "$HARNESS_ROOT_DIR/scripts/e2e/lib/upgrade-survivor/run.sh:/tmp/openclaw-upgrade-survivor-run.sh:ro" \ - ${PREPUBLISH_PLUGIN_REGISTRY_ARGS[@]+"${PREPUBLISH_PLUGIN_REGISTRY_ARGS[@]}"} \ + ${OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DOCKER_ARGS[@]+"${OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DOCKER_ARGS[@]}"} \ ${DOCKER_E2E_PACKAGE_ARGS[@]+"${DOCKER_E2E_PACKAGE_ARGS[@]}"} \ ${DOCKER_RUN_USER_ARGS[@]+"${DOCKER_RUN_USER_ARGS[@]}"} \ "$IMAGE_NAME" \ @@ -276,7 +254,23 @@ fi PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz upgrade-survivor "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}")" docker_e2e_package_mount_args "$PACKAGE_TGZ" -OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 upgrade-survivor upgrade-survivor)" +if [ -z "${OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR:-}" ]; then + AUTO_PREPUBLISH_PLUGIN_REGISTRY_ROOT="$( + mktemp -d "${TMPDIR:-/tmp}/openclaw-upgrade-survivor-plugin-registry.XXXXXX" + )" + planner_lane="upgrade-survivor" + if [ "$UPDATE_RESTART_MODE" = "auto-auth" ]; then + planner_lane="update-restart-auth" + fi + OPENCLAW_DOCKER_ALL_LANES="$planner_lane" \ + OPENCLAW_DOCKER_ALL_LOG_DIR="$AUTO_PREPUBLISH_PLUGIN_REGISTRY_ROOT" \ + OPENCLAW_DOCKER_ALL_TIMINGS=0 \ + OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS="$SCENARIO" \ + node "$HARNESS_ROOT_DIR/scripts/test-docker-all.mjs" --prepare-plugin-registry + openclaw_prepublish_plugin_registry_configure_docker_args \ + "$AUTO_PREPUBLISH_PLUGIN_REGISTRY_ROOT/prepublish-plugin-registry" +fi +OPENCLAW_TEST_STATE_FUNCTION_B64="$(docker_e2e_test_state_function_b64)" mkdir -p "$ARTIFACT_DIR" chmod -R a+rwX "$ARTIFACT_DIR" || true @@ -285,7 +279,7 @@ docker_e2e_build_or_reuse "$IMAGE_NAME" upgrade-survivor "$ROOT_DIR/scripts/e2e/ echo "Running upgrade survivor Docker E2E..." docker_e2e_run_with_harness \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ - -e OPENCLAW_TEST_STATE_SCRIPT_B64="$OPENCLAW_TEST_STATE_SCRIPT_B64" \ + -e OPENCLAW_TEST_STATE_FUNCTION_B64="$OPENCLAW_TEST_STATE_FUNCTION_B64" \ -e OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT=/tmp/openclaw-upgrade-survivor-artifacts \ -e OPENCLAW_UPGRADE_SURVIVOR_ROOT_MANAGED_VPS="$ROOT_MANAGED_VPS" \ -e OPENCLAW_UPGRADE_SURVIVOR_SCENARIO="$SCENARIO" \ @@ -294,10 +288,12 @@ docker_e2e_run_with_harness \ -e OPENCLAW_UPGRADE_SURVIVOR_START_BUDGET_SECONDS="$START_BUDGET_SECONDS" \ -e OPENCLAW_UPGRADE_SURVIVOR_STATUS_BUDGET_SECONDS="$STATUS_BUDGET_SECONDS" \ -e OPENCLAW_UPGRADE_SURVIVOR_CLAWHUB_FIXTURE_SERVER=/tmp/openclaw-clawhub-fixture-server.cjs \ + -e OPENCLAW_UPGRADE_SURVIVOR_CONFIG_PARKING_HELPER=/tmp/openclaw-config-parking.mjs \ "${PROBE_ENV_ARGS[@]}" \ -v "$ARTIFACT_DIR:/tmp/openclaw-upgrade-survivor-artifacts" \ -v "$HARNESS_ROOT_DIR/scripts/e2e/lib/clawhub-fixture-server.cjs:/tmp/openclaw-clawhub-fixture-server.cjs:ro" \ - "${PREPUBLISH_PLUGIN_REGISTRY_ARGS[@]}" \ + -v "$HARNESS_ROOT_DIR/scripts/e2e/lib/upgrade-survivor/config-parking.mjs:/tmp/openclaw-config-parking.mjs:ro" \ + "${OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DOCKER_ARGS[@]}" \ "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ "${DOCKER_RUN_USER_ARGS[@]}" \ "$IMAGE_NAME" \ @@ -331,10 +327,13 @@ export GATEWAY_AUTH_TOKEN_REF="upgrade-survivor-token" export OPENAI_API_KEY="sk-openclaw-upgrade-survivor" export DISCORD_BOT_TOKEN="upgrade-survivor-discord-token" export TELEGRAM_BOT_TOKEN="123456:upgrade-survivor-telegram-token" -if [ "${OPENCLAW_UPGRADE_SURVIVOR_SCENARIO:-base}" = "feishu-channel" ]; then +SCENARIO="${OPENCLAW_UPGRADE_SURVIVOR_SCENARIO:-base}" +if [ "$SCENARIO" = "feishu-channel" ]; then export FEISHU_APP_SECRET="upgrade-survivor-feishu-secret" fi -export BRAVE_API_KEY="BSA_upgrade_survivor_brave_key" +if [ "$SCENARIO" = "configured-plugin-installs" ] || [ "$SCENARIO" = "sqlite-volume" ]; then + export BRAVE_API_KEY="BSA_upgrade_survivor_brave_key" +fi UPDATE_RESTART_MODE="${OPENCLAW_UPGRADE_SURVIVOR_UPDATE_RESTART_MODE:-manual}" command_timeout="${OPENCLAW_UPGRADE_SURVIVOR_COMMAND_TIMEOUT:-900s}" @@ -462,7 +461,7 @@ NODE [ -n "${OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR:-}" ] || return 0 fi - openclaw_prepublish_plugin_registry_start \ + openclaw_prepublish_plugin_registry_start \ "${OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR:-}" \ "${OPENCLAW_DOCKER_E2E_SELECTED_SHA:-}" \ "$package_version" \ @@ -472,7 +471,60 @@ NODE "${registry_args[@]}" } -openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}" +install_companion_plugins() { + local authored_config="$OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT/companion-install-authored.json" + local install_status=0 + local restore_status=0 + node "$OPENCLAW_UPGRADE_SURVIVOR_CONFIG_PARKING_HELPER" \ + park-companion-install "$OPENCLAW_CONFIG_PATH" "$authored_config" + + set +e + openclaw plugins install "npm:@openclaw/discord@$package_version" --pin --accept-capabilities + install_status=$? + if [ "$install_status" -eq 0 ]; then + openclaw plugins install "clawhub:@openclaw/whatsapp@$package_version" --accept-capabilities + install_status=$? + fi + if [ "$install_status" -eq 0 ]; then + node "$OPENCLAW_UPGRADE_SURVIVOR_CLAWHUB_FIXTURE_SERVER" \ + assert-prepublish-requests "$OPENCLAW_CLAWHUB_URL" "@openclaw/whatsapp" "$package_version" + install_status=$? + fi + if [ "$install_status" -eq 0 ]; then + openclaw plugins install "npm:@openclaw/codex@$package_version" --pin --accept-capabilities + install_status=$? + fi + node "$OPENCLAW_UPGRADE_SURVIVOR_CONFIG_PARKING_HELPER" \ + restore "$OPENCLAW_CONFIG_PATH" "$authored_config" + restore_status=$? + set -e + + if [ "$install_status" -ne 0 ]; then + return "$install_status" + fi + if [ "$restore_status" -ne 0 ]; then + return "$restore_status" + fi + node scripts/e2e/lib/upgrade-survivor/assertions.mjs \ + assert-companion-installs "$package_version" +} + +openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_FUNCTION_B64:?missing OPENCLAW_TEST_STATE_FUNCTION_B64}" +if [ "$UPDATE_RESTART_MODE" = "auto-auth" ]; then + account_home="$(getent passwd "$(id -u)" | cut -d: -f6)" + if [ -z "$account_home" ]; then + echo "Could not resolve the current account home" >&2 + exit 1 + fi + openclaw_test_state_create "$account_home" upgrade-survivor + export HOME="$account_home" + export USERPROFILE="$account_home" + export OPENCLAW_STATE_DIR="$account_home/.openclaw" + export OPENCLAW_CONFIG_PATH="$OPENCLAW_STATE_DIR/openclaw.json" + unset OPENCLAW_HOME +else + openclaw_test_state_create upgrade-survivor upgrade-survivor +fi node scripts/e2e/lib/upgrade-survivor/assertions.mjs seed openclaw_e2e_install_package "$OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT/install.log" "upgrade survivor package" "$npm_config_prefix" @@ -487,13 +539,14 @@ echo "Checking dirty-state config before update..." OPENCLAW_UPGRADE_SURVIVOR_ASSERT_STAGE=baseline node scripts/e2e/lib/upgrade-survivor/assertions.mjs assert-config OPENCLAW_UPGRADE_SURVIVOR_ASSERT_STAGE=baseline node scripts/e2e/lib/upgrade-survivor/assertions.mjs assert-state configure_clawhub_fixture +configure_plugin_registry +install_companion_plugins if [ "$UPDATE_RESTART_MODE" = "auto-auth" ]; then # shellcheck disable=SC1091 source scripts/e2e/lib/upgrade-survivor/update-restart-auth.sh prepare_update_restart_probe_current_install "$PORT" "$GATEWAY_LOG" fi -configure_plugin_registry echo "Running package update against the mounted tarball..." update_args=(update --tag "${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" --yes --json) if [ "$UPDATE_RESTART_MODE" != "auto-auth" ]; then @@ -514,11 +567,6 @@ if [ "$update_status" -ne 0 ]; then openclaw_e2e_print_log /tmp/openclaw-upgrade-survivor-update.json >&2 || true exit "$update_status" fi -if [ -n "${OPENCLAW_CLAWHUB_URL:-}" ]; then - node "$OPENCLAW_UPGRADE_SURVIVOR_CLAWHUB_FIXTURE_SERVER" \ - assert-prepublish-requests "$OPENCLAW_CLAWHUB_URL" "@openclaw/whatsapp" "$package_version" -fi - if [ "$UPDATE_RESTART_MODE" = "auto-auth" ]; then echo "Skipping doctor repair until after restart proof." else diff --git a/scripts/lib/cross-os-release-checks/lane-companions.ts b/scripts/lib/cross-os-release-checks/lane-companions.ts new file mode 100644 index 000000000000..86bcf1f317f8 --- /dev/null +++ b/scripts/lib/cross-os-release-checks/lane-companions.ts @@ -0,0 +1,50 @@ +import { join } from "node:path"; +import type { LaneBaseParams, LaneState } from "./config.ts"; +import { runInstalledCli } from "./installed.ts"; +import { runTimedLanePhase } from "./reporting.ts"; +import { runOpenClaw } from "./runtime.ts"; + +export async function installLaneCompanions( + params: Pick & { + lane: LaneState; + env: NodeJS.ProcessEnv; + cliPath?: string; + }, +) { + if (params.companions.length === 0) { + return; + } + await runTimedLanePhase(params.lane, "install-companions", async () => { + for (const companion of params.companions) { + const logPath = join( + params.logsDir, + `companion-${companion.name.replace(/[^a-z0-9]+/giu, "-")}.log`, + ); + const args = [ + "plugins", + "install", + `npm-pack:${companion.tarballPath}`, + "--force", + "--accept-capabilities", + ]; + if (params.cliPath) { + await runInstalledCli({ + cliPath: params.cliPath, + args, + env: params.env, + cwd: params.lane.homeDir, + logPath, + timeoutMs: 10 * 60 * 1000, + }); + continue; + } + await runOpenClaw({ + lane: params.lane, + args, + env: params.env, + logPath, + timeoutMs: 10 * 60 * 1000, + }); + } + }); +} diff --git a/scripts/lib/cross-os-release-checks/lanes.ts b/scripts/lib/cross-os-release-checks/lanes.ts index 2c52e737625a..c8ffcc02d00c 100644 --- a/scripts/lib/cross-os-release-checks/lanes.ts +++ b/scripts/lib/cross-os-release-checks/lanes.ts @@ -55,6 +55,7 @@ import { waitForInstalledGateway, waitForInstalledGatewayToStop, } from "./installed.ts"; +import { installLaneCompanions } from "./lane-companions.ts"; import { maybeRunDiscordRoundtrip } from "./network-smokes.ts"; import { reserveGatewayPortForLane, @@ -75,45 +76,6 @@ import { } from "./runtime.ts"; import { formatError, trimForSummary } from "./shared.ts"; -async function installLaneCompanions( - params: LaneBaseParams & { - lane: LaneState; - env: NodeJS.ProcessEnv; - cliPath?: string; - }, -) { - if (params.companions.length === 0) { - return; - } - await runTimedLanePhase(params.lane, "install-companions", async () => { - for (const companion of params.companions) { - const logPath = join( - params.logsDir, - `companion-${companion.name.replace(/[^a-z0-9]+/giu, "-")}.log`, - ); - const args = ["plugins", "install", `npm-pack:${companion.tarballPath}`, "--force"]; - if (params.cliPath) { - await runInstalledCli({ - cliPath: params.cliPath, - args, - env: params.env, - cwd: params.lane.homeDir, - logPath, - timeoutMs: 10 * 60 * 1000, - }); - continue; - } - await runOpenClaw({ - lane: params.lane, - args, - env: params.env, - logPath, - timeoutMs: 10 * 60 * 1000, - }); - } - }); -} - export async function runFreshLane(params: LaneBaseParams & { build: CandidateBuild }) { const lane = createLaneState("fresh"); const cleanup: Cleanup[] = []; diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index dd817f155b5d..f594dfe73ea8 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -3312,7 +3312,7 @@ describe("update-cli", () => { }, } as OpenClawConfig; vi.mocked(readConfigFileSnapshot).mockResolvedValue(configSnapshot(config)); - loadInstalledPluginIndexInstallRecords.mockResolvedValueOnce({ + loadInstalledPluginIndexInstallRecords.mockResolvedValue({ demo: { source: "npm", spec: "@openclaw/demo@1.0.0", @@ -6574,7 +6574,7 @@ describe("update-cli", () => { const sourceConfig = { plugins: {}, } as OpenClawConfig; - loadInstalledPluginIndexInstallRecords.mockResolvedValueOnce(pluginInstallRecords); + loadInstalledPluginIndexInstallRecords.mockResolvedValue(pluginInstallRecords); vi.mocked(readConfigFileSnapshot).mockResolvedValue({ ...baseSnapshot, sourceConfig, @@ -7654,6 +7654,7 @@ describe("update-cli", () => { hash: "post-doctor", }); vi.mocked(readConfigFileSnapshot) + .mockResolvedValueOnce(preDoctorSnapshot) .mockResolvedValueOnce(preDoctorSnapshot) .mockResolvedValueOnce(preDoctorSnapshot) .mockResolvedValueOnce(postDoctorSnapshot) diff --git a/src/cli/update-cli/update-command-finalize.ts b/src/cli/update-cli/update-command-finalize.ts index d4e58a0338cc..40fd5ab0bf8c 100644 --- a/src/cli/update-cli/update-command-finalize.ts +++ b/src/cli/update-cli/update-command-finalize.ts @@ -176,52 +176,54 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis ); const channel = requestedChannel ?? storedChannel ?? effectiveChannel ?? DEFAULT_PACKAGE_CHANNEL; if (requestedChannel) { - configSnapshot = await persistRequestedUpdateChannel({ - configSnapshot, - requestedChannel, + configSnapshot = await withPluginLifecycleLease({}, async () => { + configSnapshot = await readConfigFileSnapshot({ skipPluginValidation: true }); + return await persistRequestedUpdateChannel({ + configSnapshot, + requestedChannel, + }); }); } - const completedPluginUpdate = await withPluginLifecycleLease({}, async () => { - const initialPluginUpdate = await withPrePluginUpdateDoctorEnv(async () => { - await runTimedFinalizePhase({ - finalizationStartedAt, - phaseTimings, - phase: "configSnapshot", - run: createUpdateConfigSnapshot, - }); - const doctorPreparation = await runTimedFinalizePhase({ - finalizationStartedAt, - phaseTimings, - phase: "doctor", - run: async () => { - await doctorCommand(defaultRuntime, { - nonInteractive: true, - repair: true, - yes: opts.yes === true, - }); - configSnapshot = await readConfigFileSnapshot({ skipPluginValidation: true }); - if (requestedChannel) { - configSnapshot = await persistRequestedUpdateChannel({ - configSnapshot, - requestedChannel, - }); - } - const restoredConfig = restoreDroppedPreUpdateChannels(configSnapshot, preFinalizeConfig); - configSnapshot = restoredConfig.snapshot; - const postDoctorStoredChannel = configSnapshot.valid - ? normalizeUpdateChannel(configSnapshot.config.update?.channel) - : null; - const postDoctorChannel = - requestedChannel ?? - postDoctorStoredChannel ?? - storedChannel ?? - effectiveChannel ?? - DEFAULT_PACKAGE_CHANNEL; - const pluginInstallRecords = await loadInstalledPluginIndexInstallRecords(); - return { restoredConfig, postDoctorChannel, pluginInstallRecords }; - }, - }); + const initialPluginUpdate = await withPrePluginUpdateDoctorEnv(async () => { + await runTimedFinalizePhase({ + finalizationStartedAt, + phaseTimings, + phase: "configSnapshot", + run: createUpdateConfigSnapshot, + }); + await runTimedFinalizePhase({ + finalizationStartedAt, + phaseTimings, + phase: "doctor", + run: async () => { + await doctorCommand(defaultRuntime, { + nonInteractive: true, + repair: true, + yes: opts.yes === true, + }); + }, + }); + return await withPluginLifecycleLease({}, async () => { + configSnapshot = await readConfigFileSnapshot({ skipPluginValidation: true }); + if (requestedChannel) { + configSnapshot = await persistRequestedUpdateChannel({ + configSnapshot, + requestedChannel, + }); + } + const restoredConfig = restoreDroppedPreUpdateChannels(configSnapshot, preFinalizeConfig); + configSnapshot = restoredConfig.snapshot; + const postDoctorStoredChannel = configSnapshot.valid + ? normalizeUpdateChannel(configSnapshot.config.update?.channel) + : null; + const postDoctorChannel = + requestedChannel ?? + postDoctorStoredChannel ?? + storedChannel ?? + effectiveChannel ?? + DEFAULT_PACKAGE_CHANNEL; + const pluginInstallRecords = await loadInstalledPluginIndexInstallRecords(); return await runTimedFinalizePhase({ finalizationStartedAt, phaseTimings, @@ -229,10 +231,10 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis run: async () => await updatePluginsAfterCoreUpdate({ root, - channel: doctorPreparation.postDoctorChannel, + channel: postDoctorChannel, configSnapshot, - configChanged: doctorPreparation.restoredConfig.changed, - restoredAuthoredChannels: doctorPreparation.restoredConfig.authoredChannels, + configChanged: restoredConfig.changed, + restoredAuthoredChannels: restoredConfig.authoredChannels, opts: { json: opts.json, timeout: opts.timeout, @@ -241,7 +243,7 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis acknowledgeClawHubRisk: opts.acknowledgeClawHubRisk, }, timeoutMs: timeoutMs ?? DEFAULT_UPDATE_STEP_TIMEOUT_MS, - pluginInstallRecords: doctorPreparation.pluginInstallRecords, + pluginInstallRecords, }), outcome: (result) => result.status === "error" @@ -251,26 +253,27 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis : "completed", }); }); - return await runTimedFinalizePhase({ - finalizationStartedAt, - phaseTimings, - phase: "targetConfigConvergence", - run: async () => - await completePostCorePluginUpdate({ - root, - pluginUpdate: initialPluginUpdate, - freshDoctorRequired: initialPluginUpdate.changed, - yes: opts.yes === true, - json: opts.json === true, - timeoutMs: timeoutMs ?? DEFAULT_UPDATE_STEP_TIMEOUT_MS, - }), - outcome: (result) => - result.pluginUpdate.status === "error" - ? "failed" - : result.pluginUpdate.status === "warning" - ? "warning" - : "completed", - }); + }); + // Fresh doctor acquires this same cross-process lease; completion must run after release. + const completedPluginUpdate = await runTimedFinalizePhase({ + finalizationStartedAt, + phaseTimings, + phase: "targetConfigConvergence", + run: async () => + await completePostCorePluginUpdate({ + root, + pluginUpdate: initialPluginUpdate, + freshDoctorRequired: initialPluginUpdate.changed, + yes: opts.yes === true, + json: opts.json === true, + timeoutMs: timeoutMs ?? DEFAULT_UPDATE_STEP_TIMEOUT_MS, + }), + outcome: (result) => + result.pluginUpdate.status === "error" + ? "failed" + : result.pluginUpdate.status === "warning" + ? "warning" + : "completed", }); const pluginUpdate = completedPluginUpdate.pluginUpdate; configSnapshot = completedPluginUpdate.configSnapshot; diff --git a/src/cli/update-cli/update-command-lifecycle.test.ts b/src/cli/update-cli/update-command-lifecycle.test.ts new file mode 100644 index 000000000000..d741ac121024 --- /dev/null +++ b/src/cli/update-cli/update-command-lifecycle.test.ts @@ -0,0 +1,215 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { defaultRuntime } from "../../runtime.js"; + +const mocks = vi.hoisted(() => ({ + events: [] as string[], + leaseActive: false, + readConfig: vi.fn(), +})); + +const validConfigSnapshot = { + valid: true, + parsed: {}, + config: {}, + runtimeConfig: {}, + sourceConfig: {}, + warnings: [], + issues: [], + legacyIssues: [], +}; + +const successfulPluginUpdate = { + status: "ok", + changed: true, + sync: { + changed: false, + switchedToBundled: [], + switchedToNpm: [], + warnings: [], + errors: [], + }, + npm: { changed: false, outcomes: [] }, + integrityDrifts: [], + warnings: [], +}; + +function record(name: string): void { + mocks.events.push(`${name}:${mocks.leaseActive}`); +} + +vi.mock("../../commands/doctor.js", () => ({ + doctorCommand: vi.fn(async () => { + record("doctor"); + }), +})); + +vi.mock("../../config/config.js", async (importOriginal) => ({ + ...(await importOriginal()), + assertConfigWriteAllowedInCurrentMode: vi.fn(), + readConfigFileSnapshot: mocks.readConfig, +})); + +vi.mock("../../plugins/installed-plugin-index-records.js", () => ({ + loadInstalledPluginIndexInstallRecords: vi.fn(async () => { + record("installed-records"); + return {}; + }), +})); + +vi.mock("../../plugins/installed-plugin-index-store.js", () => ({ + readPersistedInstalledPluginIndex: vi.fn(async () => { + record("persisted-index"); + return null; + }), +})); + +vi.mock("../../plugins/plugin-lifecycle-lease.js", () => ({ + withPluginLifecycleLease: async (_params: unknown, run: () => Promise) => { + mocks.events.push("lease-enter:false"); + mocks.leaseActive = true; + try { + return await run(); + } finally { + mocks.leaseActive = false; + mocks.events.push("lease-exit:false"); + } + }, +})); + +vi.mock("../../state/openclaw-state-db.paths.js", () => ({ + resolveOpenClawStateSqlitePath: vi.fn(() => "/tmp/openclaw.sqlite"), +})); + +vi.mock("../../state/openclaw-state-ownership.js", () => ({ + assertOpenClawStateWriteAllowedAtPath: vi.fn(async () => undefined), +})); + +vi.mock("./shared.js", async (importOriginal) => ({ + ...(await importOriginal()), + parseTimeoutMsOrExit: vi.fn(() => 1_000), + readPackageVersion: vi.fn(async () => "2026.8.27"), + resolveUpdateRoot: vi.fn(async () => "/tmp/openclaw"), + tryWriteCompletionCache: vi.fn(async () => "completed"), +})); + +vi.mock("./update-command-config.js", async (importOriginal) => ({ + ...(await importOriginal()), + createUpdateConfigSnapshot: vi.fn(async () => { + record("config-snapshot"); + }), + persistRequestedUpdateChannel: vi.fn(async (params: { configSnapshot: unknown }) => { + record("persist-channel"); + return params.configSnapshot; + }), + readPostCorePreUpdateSourceConfig: vi.fn(async () => ({ + sourceConfig: {}, + authoredConfig: {}, + })), + restoreDroppedPreUpdateChannels: vi.fn((snapshot: unknown) => { + record("restore-channels"); + return { + snapshot, + changed: false, + authoredChannels: [], + }; + }), +})); + +vi.mock("./update-command-fresh-doctor.js", () => ({ + completePostCorePluginUpdate: vi.fn(async () => { + record("complete"); + return { + pluginUpdate: successfulPluginUpdate, + configSnapshot: validConfigSnapshot, + }; + }), + runUpdateFinalizationDoctorInFreshProcess: vi.fn(async () => { + record("fresh-doctor"); + }), + withPrePluginUpdateDoctorEnv: async (run: () => Promise) => await run(), +})); + +vi.mock("./update-command-plugins.js", () => ({ + updatePluginsAfterCoreUpdate: vi.fn(async () => { + record("plugin-update"); + return successfulPluginUpdate; + }), +})); + +vi.mock("./update-command-post-core.js", async (importOriginal) => ({ + ...(await importOriginal()), + readPostCorePluginInstallRecordsFile: vi.fn(async () => { + record("handoff-records"); + return {}; + }), + resolvePostCoreUpdateStartedAtMs: vi.fn(async () => 1_000), + writePostCorePluginUpdateResultFile: vi.fn(async () => undefined), +})); + +import { updateFinalizeCommand } from "./update-command-finalize.js"; +import { resumePostCoreUpdate } from "./update-command-resume.js"; + +function expectLifecycleBoundary(doctorEvent: string): void { + const doctorIndex = mocks.events.indexOf(`${doctorEvent}:false`); + expect(doctorIndex).toBeGreaterThan(-1); + expect(mocks.events).not.toContain(`${doctorEvent}:true`); + const authoritativeReadIndex = mocks.events.findIndex( + (event, index) => index > doctorIndex && event === "read-config:true", + ); + expect(authoritativeReadIndex).toBeGreaterThan(doctorIndex); + for (const event of [ + "persist-channel:true", + "restore-channels:true", + "installed-records:true", + "plugin-update:true", + ]) { + expect(mocks.events).toContain(event); + } + expect(mocks.events.indexOf("plugin-update:true")).toBeGreaterThan(authoritativeReadIndex); + const lastLeaseExit = mocks.events.lastIndexOf("lease-exit:false"); + expect(mocks.events.indexOf("complete:false")).toBeGreaterThan(lastLeaseExit); +} + +describe("update plugin lifecycle lease boundaries", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + mocks.events = []; + mocks.leaseActive = false; + mocks.readConfig.mockImplementation(async () => { + record("read-config"); + return validConfigSnapshot; + }); + vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined); + vi.spyOn(defaultRuntime, "exit").mockImplementation(() => undefined as never); + vi.spyOn(defaultRuntime, "log").mockImplementation(() => undefined); + vi.spyOn(defaultRuntime, "writeJson").mockImplementation(() => undefined); + }); + + it("runs resume doctors outside the lease and rereads mutation state after acquisition", async () => { + await resumePostCoreUpdate({ + root: "/tmp/openclaw", + channel: "stable", + opts: { yes: true }, + timeoutMs: 1_000, + }); + + expectLifecycleBoundary("fresh-doctor"); + expect(mocks.events).toContain("persisted-index:true"); + expect(mocks.events).toContain("handoff-records:false"); + }); + + it("runs finalizer doctors outside the lease and rereads mutation state after acquisition", async () => { + await updateFinalizeCommand({ + channel: "stable", + deferCompletionCache: true, + json: true, + yes: true, + }); + + expectLifecycleBoundary("doctor"); + const doctorIndex = mocks.events.indexOf("doctor:false"); + expect(mocks.events.slice(0, doctorIndex)).toContain("read-config:true"); + expect(mocks.events).not.toContain("persisted-index:true"); + }); +}); diff --git a/src/cli/update-cli/update-command-post-update.test.ts b/src/cli/update-cli/update-command-post-update.test.ts index f9c8befff7df..c9d16b6e557d 100644 --- a/src/cli/update-cli/update-command-post-update.test.ts +++ b/src/cli/update-cli/update-command-post-update.test.ts @@ -7,6 +7,8 @@ import { defaultRuntime } from "../../runtime.js"; const mocks = vi.hoisted(() => ({ completePluginUpdate: vi.fn(), + leaseActive: false, + loadPluginRecords: vi.fn(), markSentinelFailure: vi.fn(async () => undefined), printResult: vi.fn(), readConfig: vi.fn(), @@ -30,7 +32,17 @@ vi.mock("../../daemon/service.js", async (importOriginal) => ({ readGatewayServiceState: mocks.readServiceState, })); vi.mock("../../plugins/plugin-lifecycle-lease.js", () => ({ - withPluginLifecycleLease: async (_params: unknown, callback: () => unknown) => callback(), + withPluginLifecycleLease: async (_params: unknown, callback: () => unknown) => { + mocks.leaseActive = true; + try { + return await callback(); + } finally { + mocks.leaseActive = false; + } + }, +})); +vi.mock("../../plugins/installed-plugin-index-records.js", () => ({ + loadInstalledPluginIndexInstallRecords: mocks.loadPluginRecords, })); vi.mock("./update-command-config.js", async (importOriginal) => ({ ...(await importOriginal()), @@ -223,6 +235,8 @@ describe("retireStandaloneGitWrapper", () => { describe("successful update finalization ordering", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.leaseActive = false; + mocks.loadPluginRecords.mockResolvedValue({}); mocks.readConfig.mockResolvedValue(validConfigSnapshot); mocks.updatePlugins.mockResolvedValue(successfulPluginUpdate); mocks.completePluginUpdate.mockResolvedValue({ @@ -267,6 +281,77 @@ describe("successful update finalization ordering", () => { } }); + it("releases the plugin lifecycle lease before fresh doctor completion", async () => { + const pluginInstallRecords = { + demo: { + source: "npm", + spec: "@acme/demo", + installPath: "/tmp/demo", + }, + }; + const ownedManagedUpdateEnv = { + ...process.env, + OPENCLAW_LIFECYCLE_TEST_MARKER: "owned", + }; + mocks.readConfig.mockImplementationOnce(async () => { + expect(mocks.leaseActive).toBe(true); + expect(process.env.OPENCLAW_LIFECYCLE_TEST_MARKER).toBe("owned"); + return validConfigSnapshot; + }); + mocks.loadPluginRecords.mockImplementationOnce(async () => { + expect(mocks.leaseActive).toBe(true); + expect(process.env.OPENCLAW_LIFECYCLE_TEST_MARKER).toBe("owned"); + return pluginInstallRecords; + }); + mocks.updatePlugins.mockImplementationOnce( + async (params: { pluginInstallRecords: unknown }) => { + expect(mocks.leaseActive).toBe(true); + expect(process.env.OPENCLAW_LIFECYCLE_TEST_MARKER).toBe("owned"); + expect(params.pluginInstallRecords).toBe(pluginInstallRecords); + return successfulPluginUpdate; + }, + ); + mocks.completePluginUpdate.mockImplementationOnce(async () => { + expect(mocks.leaseActive).toBe(false); + expect(process.env.OPENCLAW_LIFECYCLE_TEST_MARKER).toBe("owned"); + return { + pluginUpdate: successfulPluginUpdate, + configSnapshot: validConfigSnapshot, + }; + }); + + await finishUpdate({ + result: { + status: "ok", + mode: "npm", + root: "/tmp/openclaw-update", + steps: [], + durationMs: 1, + }, + root: "/tmp/openclaw-update", + installKindChanged: false, + configSnapshot: validConfigSnapshot, + requestedChannel: null, + storedChannel: null, + channel: "stable", + downgradeRisk: false, + shouldRestart: false, + opts: {}, + showProgress: false, + ownedManagedUpdateEnv, + controlPlaneUpdateSentinelMeta: {}, + preUpdatePluginInstallRecords: {}, + startedAt: Date.now(), + updateStepTimeoutMs: 1_000, + } as unknown as FinishUpdateParams); + + expect(mocks.readConfig).toHaveBeenCalledOnce(); + expect(mocks.loadPluginRecords).toHaveBeenCalledOnce(); + expect(mocks.updatePlugins).toHaveBeenCalledOnce(); + expect(mocks.completePluginUpdate).toHaveBeenCalledOnce(); + expect(mocks.leaseActive).toBe(false); + }); + it("marks and prints an error without persisting success when retirement fails", async () => { const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-finalize-failure-")); const previousRoot = path.join(home, "old-root"); diff --git a/src/cli/update-cli/update-command-post-update.ts b/src/cli/update-cli/update-command-post-update.ts index 915c441baba4..8a47e1eb9604 100644 --- a/src/cli/update-cli/update-command-post-update.ts +++ b/src/cli/update-cli/update-command-post-update.ts @@ -243,43 +243,45 @@ export async function finishUpdate(params: { if (!pluginsUpdatedInFreshProcess) { await withOwnedManagedUpdateEnv(params.ownedManagedUpdateEnv, async () => { - await withPluginLifecycleLease({}, async () => { - postUpdateConfigSnapshot = await readConfigFileSnapshot({ - skipPluginValidation: true, - suppressFutureVersionWarning: shouldResumePostCoreInFreshProcess, - }); - postUpdateConfigSnapshot = await persistRequestedUpdateChannel({ - configSnapshot: postUpdateConfigSnapshot, - requestedChannel: params.requestedChannel, - }); - const restoredConfig = restoreDroppedPreUpdateChannels( - postUpdateConfigSnapshot, - params.configSnapshot.valid - ? { - sourceConfig: params.configSnapshot.sourceConfig, - authoredConfig: isRecord(params.configSnapshot.parsed) - ? (params.configSnapshot.parsed as OpenClawConfig) - : params.configSnapshot.sourceConfig, - } - : undefined, - ); - postUpdateConfigSnapshot = restoredConfig.snapshot; - // Current-process post-core convergence still reports the pre-update - // VERSION. During downgrades, pin compatibility checks to the installed - // target so incompatible newer plugins are disabled before restart. - const postUpdateInstalledVersion = await readPackageVersion(postUpdateRoot); - const versionComparison = - postUpdateInstalledVersion && VERSION - ? compareSemverStrings(VERSION, postUpdateInstalledVersion) - : null; - const compatibilityDowngradeTarget = - versionComparison != null && versionComparison > 0 ? postUpdateInstalledVersion : null; - const previousCompatibilityHostVersion = process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION; - if (compatibilityDowngradeTarget) { - process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION = compatibilityDowngradeTarget; - } - try { - const initialPluginUpdate = await updatePluginsAfterCoreUpdate({ + const previousCompatibilityHostVersion = process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION; + let compatibilityDowngradeTarget: string | null = null; + try { + const initialPluginUpdate = await withPluginLifecycleLease({}, async () => { + postUpdateConfigSnapshot = await readConfigFileSnapshot({ + skipPluginValidation: true, + suppressFutureVersionWarning: shouldResumePostCoreInFreshProcess, + }); + postUpdateConfigSnapshot = await persistRequestedUpdateChannel({ + configSnapshot: postUpdateConfigSnapshot, + requestedChannel: params.requestedChannel, + }); + const restoredConfig = restoreDroppedPreUpdateChannels( + postUpdateConfigSnapshot, + params.configSnapshot.valid + ? { + sourceConfig: params.configSnapshot.sourceConfig, + authoredConfig: isRecord(params.configSnapshot.parsed) + ? (params.configSnapshot.parsed as OpenClawConfig) + : params.configSnapshot.sourceConfig, + } + : undefined, + ); + postUpdateConfigSnapshot = restoredConfig.snapshot; + // Current-process post-core convergence still reports the pre-update + // VERSION. During downgrades, pin compatibility checks to the installed + // target so incompatible newer plugins are disabled before restart. + const postUpdateInstalledVersion = await readPackageVersion(postUpdateRoot); + const versionComparison = + postUpdateInstalledVersion && VERSION + ? compareSemverStrings(VERSION, postUpdateInstalledVersion) + : null; + compatibilityDowngradeTarget = + versionComparison != null && versionComparison > 0 ? postUpdateInstalledVersion : null; + if (compatibilityDowngradeTarget) { + process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION = compatibilityDowngradeTarget; + } + const pluginInstallRecords = await loadInstalledPluginIndexInstallRecords(); + return await updatePluginsAfterCoreUpdate({ root: postUpdateRoot, channel: params.channel, configSnapshot: postUpdateConfigSnapshot, @@ -287,33 +289,32 @@ export async function finishUpdate(params: { restoredAuthoredChannels: restoredConfig.authoredChannels, opts: params.opts, timeoutMs: params.updateStepTimeoutMs, - pluginInstallRecords: params.preUpdatePluginInstallRecords, + pluginInstallRecords, }); - const completedPluginUpdate = await completePostCorePluginUpdate({ - root: postUpdateRoot, - pluginUpdate: initialPluginUpdate, - // Aggregate plugin changes and core install changes independently require fresh doctor. - freshDoctorRequired: - didCoreUpdateChangeInstall(params.result) || initialPluginUpdate.changed, - yes: params.opts.yes === true, - json: params.opts.json === true, - timeoutMs: params.updateStepTimeoutMs, - ...(params.packageUpdateNodeRunner - ? { nodeRunner: params.packageUpdateNodeRunner } - : {}), - }); - postCorePluginUpdate = completedPluginUpdate.pluginUpdate; - postUpdateConfigSnapshot = completedPluginUpdate.configSnapshot; - } finally { - if (compatibilityDowngradeTarget) { - if (previousCompatibilityHostVersion === undefined) { - delete process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION; - } else { - process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION = previousCompatibilityHostVersion; - } + }); + // Fresh doctor acquires this same cross-process lease; completion must run after release. + const completedPluginUpdate = await completePostCorePluginUpdate({ + root: postUpdateRoot, + pluginUpdate: initialPluginUpdate, + // Aggregate plugin changes and core install changes independently require fresh doctor. + freshDoctorRequired: + didCoreUpdateChangeInstall(params.result) || initialPluginUpdate.changed, + yes: params.opts.yes === true, + json: params.opts.json === true, + timeoutMs: params.updateStepTimeoutMs, + ...(params.packageUpdateNodeRunner ? { nodeRunner: params.packageUpdateNodeRunner } : {}), + }); + postCorePluginUpdate = completedPluginUpdate.pluginUpdate; + postUpdateConfigSnapshot = completedPluginUpdate.configSnapshot; + } finally { + if (compatibilityDowngradeTarget) { + if (previousCompatibilityHostVersion === undefined) { + delete process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION; + } else { + process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION = previousCompatibilityHostVersion; } } - }); + } }); } diff --git a/src/cli/update-cli/update-command-resume.ts b/src/cli/update-cli/update-command-resume.ts index 6c008a013c73..3232ca5ff6fd 100644 --- a/src/cli/update-cli/update-command-resume.ts +++ b/src/cli/update-cli/update-command-resume.ts @@ -39,10 +39,6 @@ type ResumePostCoreUpdateParams = { }; export async function resumePostCoreUpdate(params: ResumePostCoreUpdateParams): Promise { - return await withPluginLifecycleLease({}, async () => await resumePostCoreUpdateUnlocked(params)); -} - -async function resumePostCoreUpdateUnlocked(params: ResumePostCoreUpdateParams): Promise { if ( params.channel !== "stable" && params.channel !== "extended-stable" && @@ -53,6 +49,7 @@ async function resumePostCoreUpdateUnlocked(params: ResumePostCoreUpdateParams): defaultRuntime.exit(1); return; } + const channel = params.channel; const requestedChannelInput = process.env[POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV]?.trim() ?? ""; const requestedChannel = requestedChannelInput @@ -85,47 +82,50 @@ async function resumePostCoreUpdateUnlocked(params: ResumePostCoreUpdateParams): json: params.opts.json === true, timeoutMs: params.timeoutMs, }); - // The fresh process owns the updated migration contracts. Repair before - // plugin convergence writes config, or newly retired plugin keys can block - // the update before doctor gets a chance to migrate them. - configSnapshot = await readConfigFileSnapshot({ - skipPluginValidation: true, - suppressFutureVersionWarning: true, - }); - configSnapshot = await persistRequestedUpdateChannel({ - configSnapshot, - requestedChannel, - }); - const restoredConfig = restoreDroppedPreUpdateChannels(configSnapshot, preUpdateSourceConfig); const parentPluginInstallRecords = await readPostCorePluginInstallRecordsFile( process.env[POST_CORE_UPDATE_INSTALL_RECORDS_PATH_ENV], ); - // The updated doctor may have repaired or removed plugin installs before this process resumed. - const currentPluginInstallRecords = await loadInstalledPluginIndexInstallRecords(); - const persistedPluginIndex = await readPersistedInstalledPluginIndex(); - const hasForwardedUpdateStart = Boolean(process.env[POST_CORE_UPDATE_STARTED_AT_ENV]?.trim()); - const currentIndexIsAuthoritative = - Object.keys(currentPluginInstallRecords).length > 0 || - Boolean( - persistedPluginIndex && - hasForwardedUpdateStart && - updateStartedAtMs !== undefined && - persistedPluginIndex.generatedAtMs >= updateStartedAtMs, - ); - const pluginInstallRecords = currentIndexIsAuthoritative - ? currentPluginInstallRecords - : parentPluginInstallRecords; + const initialPluginUpdate = await withPluginLifecycleLease({}, async () => { + // The fresh process owns the updated migration contracts. Repair before + // plugin convergence writes config, or newly retired plugin keys can block + // the update before doctor gets a chance to migrate them. + configSnapshot = await readConfigFileSnapshot({ + skipPluginValidation: true, + suppressFutureVersionWarning: true, + }); + configSnapshot = await persistRequestedUpdateChannel({ + configSnapshot, + requestedChannel, + }); + const restoredConfig = restoreDroppedPreUpdateChannels(configSnapshot, preUpdateSourceConfig); + // The updated doctor may have repaired or removed plugin installs before this process resumed. + const currentPluginInstallRecords = await loadInstalledPluginIndexInstallRecords(); + const persistedPluginIndex = await readPersistedInstalledPluginIndex(); + const hasForwardedUpdateStart = Boolean(process.env[POST_CORE_UPDATE_STARTED_AT_ENV]?.trim()); + const currentIndexIsAuthoritative = + Object.keys(currentPluginInstallRecords).length > 0 || + Boolean( + persistedPluginIndex && + hasForwardedUpdateStart && + updateStartedAtMs !== undefined && + persistedPluginIndex.generatedAtMs >= updateStartedAtMs, + ); + const pluginInstallRecords = currentIndexIsAuthoritative + ? currentPluginInstallRecords + : parentPluginInstallRecords; - const initialPluginUpdate = await updatePluginsAfterCoreUpdate({ - root: params.root, - channel: params.channel, - configSnapshot: restoredConfig.snapshot, - configChanged: restoredConfig.changed, - restoredAuthoredChannels: restoredConfig.authoredChannels, - opts: params.opts, - timeoutMs: params.timeoutMs, - pluginInstallRecords, + return await updatePluginsAfterCoreUpdate({ + root: params.root, + channel, + configSnapshot: restoredConfig.snapshot, + configChanged: restoredConfig.changed, + restoredAuthoredChannels: restoredConfig.authoredChannels, + opts: params.opts, + timeoutMs: params.timeoutMs, + pluginInstallRecords, + }); }); + // Fresh doctor acquires this same cross-process lease; completion must run after release. const { pluginUpdate } = await completePostCorePluginUpdate({ root: params.root, pluginUpdate: initialPluginUpdate, diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.repair.ts b/src/commands/doctor/shared/missing-configured-plugin-install.repair.ts index b846e5f1c7f1..e4d59ec30517 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.repair.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.repair.ts @@ -248,6 +248,7 @@ async function repairMissingPluginInstallsWithLease( }, }, pluginIds: missingRecordedPluginIds, + skipDisabledPlugins: true, updateChannel, coreVersion: resolveCompatibilityHostVersion(env), logger: { diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts index 31bd812a57bc..398e9e24b725 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -2366,6 +2366,34 @@ describe("repairMissingConfiguredPluginInstalls", () => { }); it("does not install configured plugins when plugins are globally disabled", async () => { + const records = { + brave: { + source: "npm" as const, + spec: "@openclaw/brave-plugin", + installPath: "/tmp/openclaw-plugins/brave", + }, + discord: { + source: "npm" as const, + spec: "@openclaw/discord", + installPath: "/tmp/openclaw-plugins/discord", + }, + }; + mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(records); + mocks.loadPluginMetadataSnapshot.mockReturnValue({ + plugins: [], + diagnostics: [ + ...brokenPluginSnapshot("brave").diagnostics, + ...brokenPluginSnapshot("discord").diagnostics, + ], + }); + mocks.updateNpmInstalledPlugins.mockResolvedValue({ + changed: false, + config: { plugins: { installs: records } }, + outcomes: [ + { pluginId: "brave", status: "skipped", message: "disabled" }, + { pluginId: "discord", status: "skipped", message: "disabled" }, + ], + }); mocks.listChannelPluginCatalogEntries.mockReturnValue([ { id: "matrix", @@ -2417,10 +2445,15 @@ describe("repairMissingConfiguredPluginInstalls", () => { env: {}, }); + expect(mocks.updateNpmInstalledPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + pluginIds: ["brave", "discord"], + skipDisabledPlugins: true, + }), + ); expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled(); expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled(); - expect(mocks.writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled(); - expect(result).toEqual({ changes: [], warnings: [], records: {} }); + expect(result).toEqual({ changes: [], warnings: [], records }); }); it("does not install plugins merely listed in plugins.allow", async () => { diff --git a/src/flows/doctor-health-contributions-final.ts b/src/flows/doctor-health-contributions-final.ts index 38ae74a47cbe..4c78a3506c43 100644 --- a/src/flows/doctor-health-contributions-final.ts +++ b/src/flows/doctor-health-contributions-final.ts @@ -1,4 +1,5 @@ import { isExperimentalClawsEnabled } from "../claws/experimental.js"; +import { shouldDeferConfiguredPluginInstallRepair } from "../commands/doctor/shared/update-phase.js"; import { hasActiveGatewayExecCredential } from "./doctor-gateway-exec-credential.js"; import { runCoreHealthFindingNote } from "./doctor-health-contribution-core.js"; import { @@ -103,7 +104,10 @@ export function resolveFinalDoctorHealthContributions(params: { id: CHANNEL_PACKAGE_STATE_CAPABILITIES_CHECK_ID, description: "Declared channel package-state checker modules must load.", defaultEnabled: true, - async detect() { + async detect(ctx) { + if (shouldDeferConfiguredPluginInstallRepair(ctx.env ?? process.env)) { + return []; + } const { collectBundledChannelPackageStateLoadFailures } = await import("../channels/plugins/package-state-probes.js"); return collectBundledChannelPackageStateLoadFailures().map((failure) => ({ diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index 8e5eecc8ec96..4c6a24ea7eb9 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -3202,6 +3202,48 @@ describe("doctor health contributions", () => { }); }); + it("defers channel package-state loading only until post-core plugin convergence", async () => { + const contribution = requireDoctorContribution("doctor:channel-package-state-capabilities"); + mocks.collectBundledChannelPackageStateLoadFailures.mockReturnValue([ + { + detail: "plugin module path not found: /plugins/example-chat/auth-presence", + metadataKey: "persistedAuthState", + pluginId: "example-chat", + }, + ]); + mocks.runDoctorHealthRepairs.mockImplementation(async (ctx, options) => { + const findings = await options.checks[0]!.detect(ctx); + return { + config: ctx.cfg, + findings, + remainingFindings: findings, + changes: [], + warnings: [], + diffs: [], + effects: [], + checksRun: 1, + checksRepaired: 0, + checksValidated: 0, + }; + }); + vi.stubEnv("OPENCLAW_UPDATE_IN_PROGRESS", "1"); + vi.stubEnv("OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR", "1"); + const ctx = createDoctorContext(); + + await contribution.run(ctx); + + expect(mocks.collectBundledChannelPackageStateLoadFailures).not.toHaveBeenCalled(); + + vi.stubEnv("OPENCLAW_UPDATE_POST_CORE_CONVERGENCE", "1"); + + await contribution.run(ctx); + + expect(mocks.collectBundledChannelPackageStateLoadFailures).toHaveBeenCalledOnce(); + expect(ctx.runtime.log).toHaveBeenCalledWith( + expect.stringContaining("core/doctor/channel-package-state-capabilities"), + ); + }); + it("keeps channel preview warnings opt-in for default lint selection", async () => { const contribution = requireDoctorContribution("doctor:startup-channel-maintenance"); expect(contribution.healthCheckIds).toEqual([ diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 4fca543cc928..2e3d9815d921 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -217,7 +217,7 @@ function createNpmInstallConfig(params: { resolvedName?: string; resolvedSpec?: string; resolvedVersion?: string; -}) { +}): OpenClawConfig { return { plugins: { installs: { @@ -2949,6 +2949,36 @@ describe("updateNpmInstalledPlugins", () => { ]); }); + it("skips globally disabled installs before network or capability consent", async () => { + capabilityConsentMode.real = true; + const onCapabilityConsent = vi.fn(); + const config = createNpmInstallConfig({ + pluginId: "demo", + spec: "@acme/demo", + installPath: "/tmp/demo", + }); + config.plugins = { ...config.plugins, enabled: false }; + + const result = await updateNpmInstalledPlugins({ + config, + skipDisabledPlugins: true, + onCapabilityConsent, + }); + + expect(runCommandWithTimeoutMock).not.toHaveBeenCalled(); + expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled(); + expect(onCapabilityConsent).not.toHaveBeenCalled(); + expect(result.changed).toBe(false); + expect(result.config).toBe(config); + expect(result.outcomes).toEqual([ + { + pluginId: "demo", + status: "skipped", + message: 'Skipping "demo" (plugins disabled).', + }, + ]); + }); + it("updates disabled trusted official npm installs from the channel spec when requested", async () => { const installPath = createInstalledPackageDir({ name: "@openclaw/codex", diff --git a/test/scripts/clawhub-fixture-server.test.ts b/test/scripts/clawhub-fixture-server.test.ts index 434a2b73575a..219962a9c6be 100644 --- a/test/scripts/clawhub-fixture-server.test.ts +++ b/test/scripts/clawhub-fixture-server.test.ts @@ -165,64 +165,6 @@ describe("ClawHub fixture server", () => { expect(emptyAssertion.stderr).toContain("assert-no-requests requires "); }); - it("parks WhatsApp startup config and restores the authored bytes exactly", () => { - const root = tempDirs.make("openclaw-clawhub-auth-config-"); - const configPath = path.join(root, "openclaw.json"); - const snapshotPath = path.join(root, "openclaw.authored.json"); - const authoredConfig = `{ - "gateway": { "mode": "local", "reload": { "mode": "hybrid" } }, - "plugins": { - "allow": ["discord", "whatsapp"], - "entries": { "discord": { "enabled": true }, "whatsapp": { "enabled": true } } - }, - "channels": { "discord": { "enabled": true }, "whatsapp": { "enabled": true } } -} -`; - writeFileSync(configPath, authoredConfig); - - const park = spawnSync( - process.execPath, - [SCRIPT_PATH, "park-prepublish-auth-config", configPath, snapshotPath], - { encoding: "utf8", env: { ...process.env } }, - ); - expect(park.status, park.stderr).toBe(0); - expect(readFileSync(snapshotPath, "utf8")).toBe(authoredConfig); - expect(JSON.parse(readFileSync(configPath, "utf8"))).toEqual({ - gateway: { mode: "local", reload: { mode: "off" } }, - plugins: { - allow: ["discord"], - entries: { discord: { enabled: true } }, - }, - channels: { discord: { enabled: true } }, - }); - - const restore = spawnSync( - process.execPath, - [SCRIPT_PATH, "restore-prepublish-auth-config", configPath, snapshotPath], - { encoding: "utf8", env: { ...process.env } }, - ); - expect(restore.status, restore.stderr).toBe(0); - expect(readFileSync(configPath, "utf8")).toBe(authoredConfig); - }); - - it("rejects malformed probe config without changing authored bytes", () => { - const root = tempDirs.make("openclaw-clawhub-invalid-auth-config-"); - const configPath = path.join(root, "openclaw.json"); - const snapshotPath = path.join(root, "openclaw.authored.json"); - const authoredConfig = '{"plugins":{"allow":"whatsapp"}}\n'; - writeFileSync(configPath, authoredConfig); - - const park = spawnSync( - process.execPath, - [SCRIPT_PATH, "park-prepublish-auth-config", configPath, snapshotPath], - { encoding: "utf8", env: { ...process.env } }, - ); - expect(park.status).toBe(1); - expect(park.stderr).toContain("plugins.allow must be an array"); - expect(readFileSync(configPath, "utf8")).toBe(authoredConfig); - expect(existsSync(snapshotPath)).toBe(false); - }); - it("serves exact prepublish tarballs through the ClawHub artifact contract", async () => { const root = tempDirs.make("openclaw-clawhub-prepublish-"); const isolatedCwd = tempDirs.make("openclaw-clawhub-isolated-"); diff --git a/test/scripts/docker-build-helper.test.ts b/test/scripts/docker-build-helper.test.ts index e05a8c63c76c..dd03e53b8500 100644 --- a/test/scripts/docker-build-helper.test.ts +++ b/test/scripts/docker-build-helper.test.ts @@ -101,6 +101,7 @@ const RELEASE_USER_JOURNEY_SCENARIO_PATH = "scripts/e2e/lib/release-user-journey const UPGRADE_SURVIVOR_RUN_SCRIPT = "scripts/e2e/lib/upgrade-survivor/run.sh"; const UPGRADE_SURVIVOR_UPDATE_RESTART_AUTH_PATH = "scripts/e2e/lib/upgrade-survivor/update-restart-auth.sh"; +const UPGRADE_SURVIVOR_CONFIG_PARKING_PATH = "scripts/e2e/lib/upgrade-survivor/config-parking.mjs"; const GATEWAY_NETWORK_DOCKER_E2E_PATH = "scripts/e2e/gateway-network-docker.sh"; const BROWSER_CDP_SNAPSHOT_DOCKER_E2E_PATH = "scripts/e2e/browser-cdp-snapshot-docker.sh"; const SANDBOX_BROWSER_SIDECAR_DOCKER_E2E_PATH = "scripts/e2e/sandbox-browser-sidecar-docker.sh"; @@ -2525,9 +2526,13 @@ docker_e2e_docker_run_cmd run demo const publishedRunner = readFileSync(UPGRADE_SURVIVOR_RUN_SCRIPT, "utf8"); const updateRestartAuth = readFileSync(UPGRADE_SURVIVOR_UPDATE_RESTART_AUTH_PATH, "utf8"); - expect(runner.indexOf("\nconfigure_plugin_registry\n")).toBeLessThan( - runner.indexOf('\necho "Running package update against the mounted tarball..."\n'), + const runnerPluginRegistryIndex = runner.indexOf("\nconfigure_plugin_registry\n"); + const runnerCompanionInstallIndex = runner.indexOf("\ninstall_companion_plugins\n"); + const runnerUpdateIndex = runner.indexOf( + '\necho "Running package update against the mounted tarball..."\n', ); + expect(runnerPluginRegistryIndex).toBeLessThan(runnerCompanionInstallIndex); + expect(runnerCompanionInstallIndex).toBeLessThan(runnerUpdateIndex); expect( publishedRunner.indexOf("phase configure-plugin-registry configure_plugin_registry"), ).toBeLessThan(publishedRunner.indexOf("phase update-candidate update_candidate")); @@ -2535,13 +2540,11 @@ docker_e2e_docker_run_cmd run demo const runnerPrepareIndex = runner.indexOf( 'prepare_update_restart_probe_current_install "$PORT" "$GATEWAY_LOG"', ); - const runnerPluginRegistryIndex = runner.indexOf("\nconfigure_plugin_registry\n"); expect(runnerClawHubIndex).toBeGreaterThan(-1); - expect(runnerClawHubIndex).toBeLessThan(runnerPrepareIndex); - expect(runnerPrepareIndex).toBeLessThan(runnerPluginRegistryIndex); - expect(runnerPluginRegistryIndex).toBeLessThan( - runner.indexOf('\necho "Running package update against the mounted tarball..."\n'), - ); + expect(runnerClawHubIndex).toBeLessThan(runnerPluginRegistryIndex); + expect(runnerPluginRegistryIndex).toBeLessThan(runnerCompanionInstallIndex); + expect(runnerCompanionInstallIndex).toBeLessThan(runnerPrepareIndex); + expect(runnerPrepareIndex).toBeLessThan(runnerUpdateIndex); const publishedClawHubIndex = publishedRunner.indexOf( "phase configure-clawhub-fixture configure_clawhub_fixture", ); @@ -2579,19 +2582,39 @@ docker_e2e_docker_run_cmd run demo expect(publishedRunner.indexOf("phase assert-prepublish-requests node")).toBeLessThan( publishedRunner.indexOf("phase doctor run_doctor"), ); - expect(runner.indexOf('openclaw "${update_args[@]}"')).toBeLessThan( - runner.indexOf( - 'assert-prepublish-requests "$OPENCLAW_CLAWHUB_URL" "@openclaw/whatsapp" "$package_version"', - ), + const discordInstallIndex = runner.indexOf( + 'openclaw plugins install "npm:@openclaw/discord@$package_version" --pin --accept-capabilities', ); - expect( - runner.indexOf( - 'assert-prepublish-requests "$OPENCLAW_CLAWHUB_URL" "@openclaw/whatsapp" "$package_version"', - ), - ).toBeLessThan(runner.indexOf("openclaw doctor --fix --non-interactive")); - expect(runner).toContain( - 'if [ "${OPENCLAW_UPGRADE_SURVIVOR_SCENARIO:-base}" = "feishu-channel" ]; then', + const whatsappInstallIndex = runner.indexOf( + 'openclaw plugins install "clawhub:@openclaw/whatsapp@$package_version" --accept-capabilities', ); + const clawhubRequestIndex = runner.indexOf( + 'assert-prepublish-requests "$OPENCLAW_CLAWHUB_URL" "@openclaw/whatsapp" "$package_version"', + ); + const codexInstallIndex = runner.indexOf( + 'openclaw plugins install "npm:@openclaw/codex@$package_version" --pin --accept-capabilities', + ); + const restoreCompanionIndex = runner.indexOf( + 'restore "$OPENCLAW_CONFIG_PATH" "$authored_config"', + ); + const assertCompanionIndex = runner.indexOf('assert-companion-installs "$package_version"'); + expect(discordInstallIndex).toBeGreaterThan(-1); + expect(discordInstallIndex).toBeLessThan(whatsappInstallIndex); + expect(whatsappInstallIndex).toBeLessThan(clawhubRequestIndex); + expect(clawhubRequestIndex).toBeLessThan(codexInstallIndex); + expect(codexInstallIndex).toBeLessThan(restoreCompanionIndex); + expect(restoreCompanionIndex).toBeLessThan(assertCompanionIndex); + expect(assertCompanionIndex).toBeLessThan(runnerPrepareIndex); + expect(runner).toContain('park-companion-install "$OPENCLAW_CONFIG_PATH" "$authored_config"'); + expectTextToIncludeAll(runner, [ + "install_status=$?", + "restore_status=$?", + 'if [ "$install_status" -ne 0 ]; then', + 'return "$install_status"', + 'if [ "$restore_status" -ne 0 ]; then', + 'return "$restore_status"', + ]); + expect(runner).toContain('if [ "$SCENARIO" = "feishu-channel" ]; then'); expect(publishedRunner).toContain('if [ "$SCENARIO" = "feishu-channel" ]; then'); expect(publishedRunner).toContain( [ @@ -2601,6 +2624,13 @@ docker_e2e_docker_run_cmd run demo "fi", ].join("\n"), ); + expect(runner).toContain( + [ + 'if [ "$SCENARIO" = "configured-plugin-installs" ] || [ "$SCENARIO" = "sqlite-volume" ]; then', + ' export BRAVE_API_KEY="BSA_upgrade_survivor_brave_key"', + "fi", + ].join("\n"), + ); for (const script of [runner, publishedRunner]) { expectTextToIncludeAll(script, [ "prepublish-artifacts", @@ -2622,12 +2652,11 @@ docker_e2e_docker_run_cmd run demo } expectTextToIncludeAll(publishedRunner, [ "park_prepublish_authored_config", - "park-prepublish-auth-config", + "park-prepublish", "assert_prepublish_fixture_idle", "assert-no-requests", "restore_prepublish_authored_config", - "restore-prepublish-auth-config", - "cmp -s", + "config-parking.mjs", "'^(GATEWAY_AUTH_TOKEN_REF|OPENCLAW_CLAWHUB_URL)='", "OPENCLAW_CLAWHUB_URL=%s", ]); @@ -2641,15 +2670,22 @@ docker_e2e_docker_run_cmd run demo publishedRunner.lastIndexOf("write_update_restart_service_env"), ); for (const script of [runner, updateRestartAuth]) { - expect(script).not.toContain("park-prepublish-auth-config"); expect(script).not.toContain("assert-no-requests"); } + expect(updateRestartAuth).toContain("park-restart-probe"); + expect(updateRestartAuth).toContain('"$OPENCLAW_CONFIG_PATH"'); expect(publishedRunner).not.toContain( '\nexport MATRIX_ACCESS_TOKEN="upgrade-survivor-matrix-token"\n', ); expect(publishedRunner).not.toContain( '\nexport BRAVE_API_KEY="BSA_upgrade_survivor_brave_key"\n', ); + expect(runner).not.toContain('\nexport BRAVE_API_KEY="BSA_upgrade_survivor_brave_key"\n'); + expect(runner).toContain( + 'source "$HARNESS_ROOT_DIR/scripts/e2e/lib/prepublish-plugin-registry.sh"', + ); + expect(runner).toContain("openclaw_prepublish_plugin_registry_configure_docker_args"); + expect(runner).not.toContain("configure_prepublish_plugin_registry()"); expect( runner.match( /-v "\$HARNESS_ROOT_DIR\/scripts\/e2e\/lib\/clawhub-fixture-server\.cjs:\/tmp\/openclaw-clawhub-fixture-server\.cjs:ro"/gu, @@ -2660,6 +2696,16 @@ docker_e2e_docker_run_cmd run demo /-e OPENCLAW_UPGRADE_SURVIVOR_CLAWHUB_FIXTURE_SERVER=\/tmp\/openclaw-clawhub-fixture-server\.cjs/gu, ), ).toHaveLength(2); + expect( + runner.match( + /-v "\$HARNESS_ROOT_DIR\/scripts\/e2e\/lib\/upgrade-survivor\/config-parking\.mjs:\/tmp\/openclaw-config-parking\.mjs:ro"/gu, + ), + ).toHaveLength(2); + expect( + runner.match( + /-e OPENCLAW_UPGRADE_SURVIVOR_CONFIG_PARKING_HELPER=\/tmp\/openclaw-config-parking\.mjs/gu, + ), + ).toHaveLength(2); }); it("keeps upgrade survivor wrappers and the embedded payload valid bash", () => { @@ -3123,6 +3169,200 @@ fi ); }); + it("scopes candidate device identity doctor markers to the doctor process", () => { + const workDir = tempDirs.make("openclaw-upgrade-survivor-doctor-env-"); + writeExecutables(join(workDir, "bin"), { + openclaw: `#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "$@" >"$CAPTURE_DIR/doctor-argv" +{ + printf 'OPENCLAW_UPDATE_IN_PROGRESS=%s\\n' "\${OPENCLAW_UPDATE_IN_PROGRESS-unset}" + printf 'OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR=%s\\n' "\${OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR-unset}" + printf 'OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE=%s\\n' "\${OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE-unset}" +} >"$CAPTURE_DIR/doctor-env" +exit 23 +`, + }); + + const script = repoShell(workDir)` +export PATH="$TMPDIR/bin:$PATH" +export CAPTURE_DIR="$TMPDIR" +export OPENCLAW_CONFIG_PATH="$TMPDIR/openclaw.json" +export OPENCLAW_UPGRADE_SURVIVOR_CONFIG_PARKING_HELPER="$ROOT_DIR/${UPGRADE_SURVIVOR_CONFIG_PARKING_PATH}" +printf '%s\n' '{"gateway":{"mode":"local"}}' >"$OPENCLAW_CONFIG_PATH" +unset OPENCLAW_UPDATE_IN_PROGRESS +unset OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR +unset OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE +source "$ROOT_DIR/${UPGRADE_SURVIVOR_UPDATE_RESTART_AUTH_PATH}" +install_update_restart_systemctl_shim() { :; } +seed_update_restart_probe_device_auth() { :; } +openclaw_e2e_maybe_timeout() { + shift + "$@" +} +if prepare_update_restart_probe_current_install 18789 "$TMPDIR/gateway.log" >/dev/null 2>&1; then + echo "doctor unexpectedly succeeded" >&2 + exit 3 +fi +{ + printf 'OPENCLAW_UPDATE_IN_PROGRESS=%s\\n' "\${OPENCLAW_UPDATE_IN_PROGRESS-unset}" + printf 'OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR=%s\\n' "\${OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR-unset}" + printf 'OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE=%s\\n' "\${OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE-unset}" +} >"$CAPTURE_DIR/parent-env" +`; + + const result = spawnSync("bash", ["-lc", script], { encoding: "utf8" }); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(readFileSync(join(workDir, "doctor-argv"), "utf8").trimEnd().split("\n")).toEqual([ + "doctor", + "--fix", + "--non-interactive", + ]); + expect(readFileSync(join(workDir, "doctor-env"), "utf8")).toBe( + [ + "OPENCLAW_UPDATE_IN_PROGRESS=1", + "OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR=1", + "OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE=1", + "", + ].join("\n"), + ); + expect(readFileSync(join(workDir, "parent-env"), "utf8")).toBe( + [ + "OPENCLAW_UPDATE_IN_PROGRESS=unset", + "OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR=unset", + "OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE=unset", + "", + ].join("\n"), + ); + }); + + it.each([ + ["doctor", 41], + ["readiness", 42], + ["service-env", 43], + ["install", 44], + ] as const)( + "restores the canonical authored config after %s failure", + (failureStage, expectedStatus) => { + const workDir = tempDirs.make(`openclaw-upgrade-survivor-${failureStage}-failure-`); + writeExecutables(join(workDir, "bin"), { + openclaw: `#!/usr/bin/env bash +set -euo pipefail +printf '%s %s\n' "$OPENCLAW_CONFIG_PATH" "$*" >>"$CAPTURE_DIR/openclaw-calls" +if [ "$FAILURE_STAGE" = doctor ] && [ "\${1:-}" = doctor ]; then + exit 41 +fi +if [ "\${1:-}" = gateway ] && [ "\${2:-}" = install ]; then + [ "$FAILURE_STAGE" != install ] || exit 44 + exit 0 +fi +sleep 30 +`, + }); + + const script = repoShell(workDir)` +export PATH="$TMPDIR/bin:$PATH" +export CAPTURE_DIR="$TMPDIR" +export FAILURE_STAGE="${failureStage}" +export OPENCLAW_STATE_DIR="$TMPDIR/state" +export OPENCLAW_CONFIG_PATH="$OPENCLAW_STATE_DIR/openclaw.json" +export OPENCLAW_UPGRADE_SURVIVOR_CONFIG_PARKING_HELPER="$ROOT_DIR/${UPGRADE_SURVIVOR_CONFIG_PARKING_PATH}" +export OPENCLAW_UPGRADE_SURVIVOR_SYSTEMCTL_SHIM_PID_FILE="$TMPDIR/gateway.pid" +export OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SERVICE_INSTALL_JSON="$TMPDIR/install.json" +export OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SERVICE_INSTALL_ERR="$TMPDIR/install.err" +export GATEWAY_AUTH_TOKEN_REF=upgrade-survivor-token +mkdir -p "$OPENCLAW_STATE_DIR" +authored_config='{"channels":{"discord":{"dm":{"policy":"allowlist","allowFrom":["123"]}}}}' +printf '%s\n' "$authored_config" >"$OPENCLAW_CONFIG_PATH" +source "$ROOT_DIR/${UPGRADE_SURVIVOR_UPDATE_RESTART_AUTH_PATH}" +install_update_restart_systemctl_shim() { :; } +seed_update_restart_probe_device_auth() { :; } +openclaw_e2e_maybe_timeout() { + shift + "$@" +} +openclaw_e2e_wait_gateway_ready() { + [ "$FAILURE_STAGE" != readiness ] || return 42 +} +write_update_restart_service_auth_env() { + [ "$FAILURE_STAGE" != service-env ] || return 43 +} +status=0 +prepare_update_restart_probe_current_install 18789 "$TMPDIR/gateway.log" >/dev/null 2>&1 || status=$? +printf '%s\n' "$status" >"$CAPTURE_DIR/status" +cmp -s "$OPENCLAW_CONFIG_PATH" <(printf '%s\n' "$authored_config") +[ ! -e "$TMPDIR/gateway.log.authored-config" ] +if [ -n "\${gateway_pid:-}" ]; then + kill "$gateway_pid" >/dev/null 2>&1 || true + wait "$gateway_pid" >/dev/null 2>&1 || true +fi +`; + + const result = spawnSync("bash", ["-lc", script], { encoding: "utf8" }); + + expect(result.status, result.stderr).toBe(0); + expect(readFileSync(join(workDir, "status"), "utf8")).toBe(`${expectedStatus}\n`); + const calls = readFileSync(join(workDir, "openclaw-calls"), "utf8"); + expect(calls).toContain(join(workDir, "state", "openclaw.json")); + expect(calls).not.toContain("OPENCLAW_CONFIG_PATH="); + }, + ); + + it("prefers restore failure and retains the authored config snapshot", () => { + const workDir = tempDirs.make("openclaw-upgrade-survivor-restore-failure-"); + writeExecutables(join(workDir, "bin"), { + openclaw: `#!/usr/bin/env bash +set -euo pipefail +exit 41 +`, + "config-parking-wrapper.mjs": `import { spawnSync } from "node:child_process"; + +const args = process.argv.slice(2); +if (args[0] === "restore") { + process.exit(57); +} +const result = spawnSync( + process.execPath, + [process.env.REAL_CONFIG_PARKING_HELPER, ...args], + { stdio: "inherit", env: process.env }, +); +process.exit(result.status ?? 1); +`, + }); + + const script = repoShell(workDir)` +export PATH="$TMPDIR/bin:$PATH" +export OPENCLAW_STATE_DIR="$TMPDIR/state" +export OPENCLAW_CONFIG_PATH="$OPENCLAW_STATE_DIR/openclaw.json" +export REAL_CONFIG_PARKING_HELPER="$ROOT_DIR/${UPGRADE_SURVIVOR_CONFIG_PARKING_PATH}" +export OPENCLAW_UPGRADE_SURVIVOR_CONFIG_PARKING_HELPER="$TMPDIR/bin/config-parking-wrapper.mjs" +mkdir -p "$OPENCLAW_STATE_DIR" +printf '%s\n' '{"channels":{"discord":{"dm":{"policy":"allowlist"}}}}' >"$OPENCLAW_CONFIG_PATH" +source "$ROOT_DIR/${UPGRADE_SURVIVOR_UPDATE_RESTART_AUTH_PATH}" +install_update_restart_systemctl_shim() { :; } +seed_update_restart_probe_device_auth() { :; } +openclaw_e2e_maybe_timeout() { + shift + "$@" +} +status=0 +prepare_update_restart_probe_current_install 18789 "$TMPDIR/gateway.log" >/dev/null 2>&1 || status=$? +printf '%s\n' "$status" >"$TMPDIR/status" +`; + + const result = spawnSync("bash", ["-lc", script], { encoding: "utf8" }); + + expect(result.status, result.stderr).toBe(0); + expect(readFileSync(join(workDir, "status"), "utf8")).toBe("57\n"); + expect(existsSync(join(workDir, "gateway.log.authored-config"))).toBe(true); + expect(JSON.parse(readFileSync(join(workDir, "state", "openclaw.json"), "utf8"))).toEqual({ + plugins: { enabled: false }, + gateway: expect.objectContaining({ reload: { mode: "off" } }), + }); + }); + it("keeps upgrade survivor auto-auth success summary set -u safe", () => { const runner = readFileSync(UPGRADE_SURVIVOR_DOCKER_E2E_PATH, "utf8"); const summaryDefaultIndex = runner.indexOf('startup_summary="n/a"'); @@ -5179,8 +5419,16 @@ done }); it("uses the account home for upgrade survivor auto-auth state", () => { - const runner = readFileSync(UPGRADE_SURVIVOR_RUN_SCRIPT, "utf8"); - expectTextToIncludeAll(runner, [ + const publishedRunner = readFileSync(UPGRADE_SURVIVOR_RUN_SCRIPT, "utf8"); + const wrapper = readFileSync(UPGRADE_SURVIVOR_DOCKER_E2E_PATH, "utf8"); + const directRunner = extractUpgradeSurvivorPayload(wrapper); + + expectTextToIncludeAll(wrapper, [ + 'OPENCLAW_TEST_STATE_FUNCTION_B64="$(docker_e2e_test_state_function_b64)"', + '-e OPENCLAW_TEST_STATE_FUNCTION_B64="$OPENCLAW_TEST_STATE_FUNCTION_B64"', + ]); + expect(wrapper).not.toContain("OPENCLAW_TEST_STATE_SCRIPT_B64"); + expectTextToIncludeAll(publishedRunner, [ 'if [ "$UPDATE_RESTART_MODE" = "auto-auth" ]; then', 'account_home="$(getent passwd "$(id -u)" | cut -d: -f6)"', 'if [ -z "$account_home" ]; then', @@ -5191,12 +5439,39 @@ done 'export OPENCLAW_CONFIG_PATH="$OPENCLAW_STATE_DIR/openclaw.json"', ]); - expect(runner.indexOf("unset OPENCLAW_HOME")).toBeLessThan( - runner.indexOf('export OPENCLAW_STATE_DIR="$account_home/.openclaw"'), + expect(publishedRunner.indexOf("unset OPENCLAW_HOME")).toBeLessThan( + publishedRunner.indexOf('export OPENCLAW_STATE_DIR="$account_home/.openclaw"'), ); expect( - runner.indexOf('export OPENCLAW_CONFIG_PATH="$OPENCLAW_STATE_DIR/openclaw.json"'), - ).toBeLessThan(runner.indexOf("node scripts/e2e/lib/upgrade-survivor/assertions.mjs seed")); + publishedRunner.indexOf('export OPENCLAW_CONFIG_PATH="$OPENCLAW_STATE_DIR/openclaw.json"'), + ).toBeLessThan( + publishedRunner.indexOf("node scripts/e2e/lib/upgrade-survivor/assertions.mjs seed"), + ); + + expectTextToIncludeAll(directRunner, [ + 'openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_FUNCTION_B64:?missing OPENCLAW_TEST_STATE_FUNCTION_B64}"', + 'if [ "$UPDATE_RESTART_MODE" = "auto-auth" ]; then', + 'account_home="$(getent passwd "$(id -u)" | cut -d: -f6)"', + 'openclaw_test_state_create "$account_home" upgrade-survivor', + 'export HOME="$account_home"', + 'export USERPROFILE="$account_home"', + 'export OPENCLAW_STATE_DIR="$account_home/.openclaw"', + 'export OPENCLAW_CONFIG_PATH="$OPENCLAW_STATE_DIR/openclaw.json"', + "unset OPENCLAW_HOME", + "else", + "openclaw_test_state_create upgrade-survivor upgrade-survivor", + ]); + expect(directRunner.indexOf('openclaw_test_state_create "$account_home"')).toBeLessThan( + directRunner.indexOf("unset OPENCLAW_HOME"), + ); + expect(directRunner.indexOf("unset OPENCLAW_HOME")).toBeLessThan( + directRunner.indexOf("prepare_update_restart_probe_current_install"), + ); + expect( + directRunner.indexOf('export OPENCLAW_CONFIG_PATH="$OPENCLAW_STATE_DIR/openclaw.json"'), + ).toBeLessThan( + directRunner.indexOf("node scripts/e2e/lib/upgrade-survivor/assertions.mjs seed"), + ); }); it("bounds doctor install switch command log diagnostics", () => { diff --git a/test/scripts/openclaw-cross-os-release-checks-lanes.test.ts b/test/scripts/openclaw-cross-os-release-checks-lanes.test.ts new file mode 100644 index 000000000000..d84337a28066 --- /dev/null +++ b/test/scripts/openclaw-cross-os-release-checks-lanes.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { LaneState } from "../../scripts/lib/cross-os-release-checks/config.ts"; + +const mocks = vi.hoisted(() => ({ + runInstalledCli: vi.fn().mockResolvedValue(undefined), + runOpenClaw: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../../scripts/lib/cross-os-release-checks/installed.ts", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../../scripts/lib/cross-os-release-checks/installed.ts") + >()), + runInstalledCli: mocks.runInstalledCli, +})); + +vi.mock("../../scripts/lib/cross-os-release-checks/runtime.ts", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../../scripts/lib/cross-os-release-checks/runtime.ts") + >()), + runOpenClaw: mocks.runOpenClaw, +})); + +import { installLaneCompanions } from "../../scripts/lib/cross-os-release-checks/lane-companions.ts"; + +function createLane(): LaneState { + return { + name: "fresh", + rootDir: "/tmp/openclaw-release", + prefixDir: "/tmp/openclaw-release/prefix", + homeDir: "/tmp/openclaw-release/home", + stateDir: "/tmp/openclaw-release/state", + appDataDir: "/tmp/openclaw-release/app-data", + gatewayPort: 18789, + phaseTimings: [], + }; +} + +describe("cross-OS release companion installation", () => { + afterEach(() => { + mocks.runInstalledCli.mockClear(); + mocks.runOpenClaw.mockClear(); + }); + + it.each([ + { cliPath: undefined, runner: "packaged" }, + { cliPath: "/tmp/openclaw", runner: "installed" }, + ] as const)("accepts declared capabilities through the $runner runner", async ({ cliPath }) => { + const lane = createLane(); + const env = { HOME: lane.homeDir }; + + await installLaneCompanions({ + companions: [{ name: "@openclaw/codex", tarballPath: "/tmp/openclaw-codex.tgz" }], + logsDir: "/tmp/openclaw-release/logs", + lane, + env, + ...(cliPath ? { cliPath } : {}), + }); + + const expectedArgs = [ + "plugins", + "install", + "npm-pack:/tmp/openclaw-codex.tgz", + "--force", + "--accept-capabilities", + ]; + const expectedCall = expect.objectContaining({ args: expectedArgs, env }); + if (cliPath) { + expect(mocks.runInstalledCli).toHaveBeenCalledWith(expectedCall); + expect(mocks.runOpenClaw).not.toHaveBeenCalled(); + } else { + expect(mocks.runOpenClaw).toHaveBeenCalledWith(expectedCall); + expect(mocks.runInstalledCli).not.toHaveBeenCalled(); + } + }); +}); diff --git a/test/scripts/upgrade-survivor-assertions.test.ts b/test/scripts/upgrade-survivor-assertions.test.ts index f58c5cb3fab8..9e18c1af1830 100644 --- a/test/scripts/upgrade-survivor-assertions.test.ts +++ b/test/scripts/upgrade-survivor-assertions.test.ts @@ -1,5 +1,6 @@ // Upgrade Survivor Assertions tests cover upgrade survivor assertions script behavior. import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -268,6 +269,7 @@ function assertConfig(params: { acceptedIntents: string[]; config: unknown; scenario: string; + stage?: "baseline" | "survival"; }): void { const root = mkdtempSync(join(tmpdir(), "openclaw-upgrade-survivor-config-")); try { @@ -285,6 +287,120 @@ function assertConfig(params: { OPENCLAW_CONFIG_PATH: configPath, OPENCLAW_UPGRADE_SURVIVOR_CONFIG_COVERAGE_JSON: coveragePath, OPENCLAW_UPGRADE_SURVIVOR_SCENARIO: params.scenario, + OPENCLAW_UPGRADE_SURVIVOR_ASSERT_STAGE: params.stage ?? "survival", + }, + stdio: "pipe", + }); + } finally { + rmSync(root, { force: true, recursive: true }); + } +} + +const ACCEPTED_SURFACE = { + channels: [], + providers: [], + tools: [], + contracts: [], + hooks: [], + mcpServers: [], + cliCommands: [], + cliBackends: [], + skills: [], + dangerousConfigFlags: [], +}; + +function acceptedSurfaceHash(): string { + return createHash("sha256").update(JSON.stringify(ACCEPTED_SURFACE)).digest("hex"); +} + +function assertCompanionPluginRecords( + mutate?: ( + records: Record>, + installPaths: Record<"codex" | "discord" | "whatsapp", string>, + ) => void, +): void { + const root = mkdtempSync(join(tmpdir(), "openclaw-upgrade-survivor-companions-")); + try { + const stateDir = join(root, "state"); + const version = "2026.8.1"; + const discordInstallPath = join( + stateDir, + "npm", + "projects", + "discord", + "node_modules", + "@openclaw", + "discord", + ); + const codexInstallPath = join( + stateDir, + "npm", + "projects", + "codex", + "node_modules", + "@openclaw", + "codex", + ); + const whatsappInstallPath = join(stateDir, "extensions", "whatsapp"); + for (const [installPath, packageName] of [ + [discordInstallPath, "@openclaw/discord"], + [whatsappInstallPath, "@openclaw/whatsapp"], + [codexInstallPath, "@openclaw/codex"], + ] as const) { + mkdirSync(installPath, { recursive: true }); + writeJson(join(installPath, "package.json"), { name: packageName, version }); + } + const npmIntegrity = "sha512-upgrade-survivor"; + const clawpackSha256 = "a".repeat(64); + const consent = (integrity: string) => ({ + acceptedSurface: ACCEPTED_SURFACE, + acceptedSurfaceHash: acceptedSurfaceHash(), + acceptedSurfaceAt: "2026-08-27T00:00:00.000Z", + acceptedSurfaceIntegrity: integrity, + }); + const records: Record> = { + discord: { + source: "npm", + spec: `@openclaw/discord@${version}`, + resolvedName: "@openclaw/discord", + resolvedVersion: version, + integrity: npmIntegrity, + installPath: discordInstallPath, + ...consent(npmIntegrity), + }, + whatsapp: { + source: "clawhub", + spec: `clawhub:@openclaw/whatsapp@${version}`, + version, + clawhubPackage: "@openclaw/whatsapp", + clawhubChannel: "official", + artifactKind: "npm-pack", + clawpackSha256, + installPath: whatsappInstallPath, + ...consent(clawpackSha256), + }, + codex: { + source: "npm", + spec: `@openclaw/codex@${version}`, + resolvedName: "@openclaw/codex", + resolvedVersion: version, + integrity: npmIntegrity, + installPath: codexInstallPath, + ...consent(npmIntegrity), + }, + }; + mutate?.(records, { + codex: codexInstallPath, + discord: discordInstallPath, + whatsapp: whatsappInstallPath, + }); + mkdirSync(join(stateDir, "plugins"), { recursive: true }); + writeJson(join(stateDir, "plugins", "installs.json"), { installRecords: records }); + + execFileSync(process.execPath, [ASSERTIONS_PATH, "assert-companion-installs", version], { + env: { + ...process.env, + OPENCLAW_STATE_DIR: stateDir, }, stdio: "pipe", }); @@ -527,6 +643,112 @@ describe("upgrade survivor assertions", () => { ).not.toThrow(); }); + it("allows legacy Discord DM config only at the baseline stage", () => { + const legacyConfig = { + channels: { + discord: { + enabled: true, + dm: { policy: "allowlist", allowFrom: ["111111111111111111"] }, + guilds: { + "222222222222222222": { + channels: { "333333333333333333": { requireMention: true } }, + }, + }, + threadBindings: { idleHours: 72 }, + }, + }, + }; + expect(() => + assertConfig({ + acceptedIntents: ["discord-channel"], + config: legacyConfig, + scenario: "base", + stage: "baseline", + }), + ).not.toThrow(); + expect(() => + assertConfig({ + acceptedIntents: ["discord-channel"], + config: legacyConfig, + scenario: "base", + }), + ).toThrow(/legacy Discord DM config survived/); + }); + + it("requires canonical Discord DM config after update", () => { + expect(() => + assertConfig({ + acceptedIntents: ["discord-channel"], + config: { + channels: { + discord: { + enabled: true, + dmPolicy: "allowlist", + allowFrom: ["111111111111111111"], + guilds: { + "222222222222222222": { + channels: { "333333333333333333": { requireMention: true } }, + }, + }, + threadBindings: { idleHours: 72 }, + }, + }, + }, + scenario: "base", + }), + ).not.toThrow(); + }); + + it("requires exact artifact-bound consent for direct companion installs", () => { + expect(() => assertCompanionPluginRecords()).not.toThrow(); + expect(() => + assertCompanionPluginRecords((records) => { + const discord = records.discord; + if (!discord) { + throw new Error("discord fixture missing"); + } + Reflect.deleteProperty(discord, "acceptedSurfaceIntegrity"); + }), + ).toThrow(/discord plugin consent integrity/); + }); + + it.each([ + ["npm", "discord", "resolvedVersion", "version"], + ["ClawHub", "whatsapp", "version", "resolvedVersion"], + ] as const)( + "requires the source-native version field for %s companion installs", + (_sourceLabel, pluginId, requiredField, alternateField) => { + expect(() => + assertCompanionPluginRecords((records) => { + const record = records[pluginId]; + if (!record) { + throw new Error(`${pluginId} fixture missing`); + } + record[alternateField] = record[requiredField]; + Reflect.deleteProperty(record, requiredField); + }), + ).toThrow(new RegExp(`${pluginId} plugin version changed`)); + }, + ); + + it.each([ + ["npm", "discord"], + ["ClawHub", "whatsapp"], + ] as const)( + "requires the installed package version to match for %s companion installs", + (_sourceLabel, pluginId) => { + expect(() => + assertCompanionPluginRecords((_records, installPaths) => { + const packageName = pluginId === "discord" ? "@openclaw/discord" : "@openclaw/whatsapp"; + writeJson(join(installPaths[pluginId], "package.json"), { + name: packageName, + version: "2026.8.0", + }); + }), + ).toThrow(new RegExp(`${pluginId} installed package version changed`)); + }, + ); + it("accepts official ClawHub npm-pack installs for configured external plugins", () => { expect(() => assertConfiguredPluginState()).not.toThrow(); }); diff --git a/test/scripts/upgrade-survivor-config-parking.test.ts b/test/scripts/upgrade-survivor-config-parking.test.ts new file mode 100644 index 000000000000..d93a15ef550a --- /dev/null +++ b/test/scripts/upgrade-survivor-config-parking.test.ts @@ -0,0 +1,193 @@ +import { spawnSync } from "node:child_process"; +import { chmodSync, existsSync, mkdirSync, readFileSync, 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 = path.resolve("scripts/e2e/lib/upgrade-survivor/config-parking.mjs"); +const SURVIVOR_SCRIPT_PATH = path.resolve("scripts/e2e/upgrade-survivor-docker.sh"); +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +function run(...args: string[]) { + return spawnSync(process.execPath, [SCRIPT_PATH, ...args], { + encoding: "utf8", + env: { ...process.env }, + }); +} + +describe("upgrade survivor config parking", () => { + it("preserves published prepublish parking behavior and restores exact bytes", () => { + const root = tempDirs.make("openclaw-prepublish-config-parking-"); + const configPath = path.join(root, "openclaw.json"); + const snapshotPath = path.join(root, "openclaw.authored.json"); + const authoredConfig = `{ + "gateway": { "mode": "local", "reload": { "mode": "hybrid" } }, + "plugins": { + "allow": ["discord", "whatsapp"], + "entries": { "discord": { "enabled": true }, "whatsapp": { "enabled": true } } + }, + "channels": { "discord": { "enabled": true }, "whatsapp": { "enabled": true } } +} +`; + writeFileSync(configPath, authoredConfig); + + const park = run("park-prepublish", configPath, snapshotPath); + expect(park.status, park.stderr).toBe(0); + expect(readFileSync(snapshotPath, "utf8")).toBe(authoredConfig); + expect(JSON.parse(readFileSync(configPath, "utf8"))).toEqual({ + gateway: { mode: "local", reload: { mode: "off" } }, + plugins: { + allow: ["discord"], + entries: { discord: { enabled: true } }, + }, + channels: { discord: { enabled: true } }, + }); + + const restore = run("restore", configPath, snapshotPath); + expect(restore.status, restore.stderr).toBe(0); + expect(readFileSync(configPath, "utf8")).toBe(authoredConfig); + expect(existsSync(snapshotPath)).toBe(false); + }); + + it("parks legacy authored config behind a strict restart probe config", () => { + const root = tempDirs.make("openclaw-restart-config-parking-"); + const configPath = path.join(root, "openclaw.json"); + const snapshotPath = path.join(root, "openclaw.authored.json"); + const authoredConfig = + '{"channels":{"discord":{"dm":{"policy":"allowlist","allowFrom":["123"]}}}}\n'; + writeFileSync(configPath, authoredConfig); + + const park = run("park-restart-probe", configPath, snapshotPath, "19876"); + expect(park.status, park.stderr).toBe(0); + expect(readFileSync(snapshotPath, "utf8")).toBe(authoredConfig); + expect(JSON.parse(readFileSync(configPath, "utf8"))).toEqual({ + plugins: { enabled: false }, + gateway: { + port: 19876, + mode: "local", + bind: "loopback", + controlUi: { enabled: false }, + auth: { + mode: "token", + token: { + source: "env", + provider: "default", + id: "GATEWAY_AUTH_TOKEN_REF", + }, + }, + reload: { mode: "off" }, + }, + }); + }); + + it("parks companion installs behind a plugin-disabled config and restores exact bytes", () => { + const root = tempDirs.make("openclaw-companion-config-parking-"); + const configPath = path.join(root, "openclaw.json"); + const snapshotPath = path.join(root, "openclaw.authored.json"); + const authoredConfig = + '{"channels":{"discord":{"dm":{"policy":"allowlist","allowFrom":["123"]}}}}\n'; + writeFileSync(configPath, authoredConfig); + + const park = run("park-companion-install", configPath, snapshotPath); + expect(park.status, park.stderr).toBe(0); + expect(readFileSync(snapshotPath, "utf8")).toBe(authoredConfig); + expect(JSON.parse(readFileSync(configPath, "utf8"))).toEqual({ + plugins: { enabled: false }, + }); + + writeFileSync(configPath, '{"plugins":{"allow":["discord"]}}\n'); + const restore = run("restore", configPath, snapshotPath); + expect(restore.status, restore.stderr).toBe(0); + expect(readFileSync(configPath, "utf8")).toBe(authoredConfig); + expect(existsSync(snapshotPath)).toBe(false); + }); + + it("restores authored bytes and preserves the failing companion install status", () => { + const root = tempDirs.make("openclaw-companion-install-failure-"); + const binDir = path.join(root, "bin"); + const configPath = path.join(root, "openclaw.json"); + const invocationPath = path.join(root, "openclaw-invocations"); + const runnerPath = path.join(root, "run-companion-install.sh"); + const authoredConfig = + '{"channels":{"discord":{"dm":{"policy":"allowlist","allowFrom":["123"]}}}}\n'; + mkdirSync(binDir); + writeFileSync(configPath, authoredConfig); + const survivorScript = readFileSync(SURVIVOR_SCRIPT_PATH, "utf8"); + const functionStart = survivorScript.indexOf("install_companion_plugins() {"); + const functionEnd = survivorScript.indexOf( + "\n}\n\nopenclaw_e2e_eval_test_state_from_b64", + functionStart, + ); + expect(functionStart).toBeGreaterThan(-1); + expect(functionEnd).toBeGreaterThan(functionStart); + const functionSource = survivorScript.slice(functionStart, functionEnd + 2); + writeFileSync( + path.join(binDir, "openclaw"), + `#!/usr/bin/env bash +set -euo pipefail +count=0 +if [ -f "$OPENCLAW_INVOCATION_PATH" ]; then + count="$(cat "$OPENCLAW_INVOCATION_PATH")" +fi +count=$((count + 1)) +printf '%s' "$count" >"$OPENCLAW_INVOCATION_PATH" +if [ "$count" -eq 2 ]; then + exit 23 +fi +`, + ); + chmodSync(path.join(binDir, "openclaw"), 0o755); + writeFileSync( + runnerPath, + `#!/usr/bin/env bash +set -euo pipefail +${functionSource} +install_companion_plugins +`, + ); + + const result = spawnSync("bash", [runnerPath], { + encoding: "utf8", + env: { + ...process.env, + OPENCLAW_CONFIG_PATH: configPath, + OPENCLAW_INVOCATION_PATH: invocationPath, + OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT: root, + OPENCLAW_UPGRADE_SURVIVOR_CONFIG_PARKING_HELPER: SCRIPT_PATH, + OPENCLAW_UPGRADE_SURVIVOR_CLAWHUB_FIXTURE_SERVER: "unused", + PATH: `${binDir}:${process.env.PATH ?? ""}`, + package_version: "2026.8.1", + }, + }); + + expect(result.status, result.stderr).toBe(23); + expect(readFileSync(configPath, "utf8")).toBe(authoredConfig); + expect(existsSync(path.join(root, "companion-install-authored.json"))).toBe(false); + }); + + it("rejects malformed config without changing authored bytes", () => { + const root = tempDirs.make("openclaw-invalid-config-parking-"); + const configPath = path.join(root, "openclaw.json"); + const snapshotPath = path.join(root, "openclaw.authored.json"); + const authoredConfig = '{"plugins":{"allow":"whatsapp"}}\n'; + writeFileSync(configPath, authoredConfig); + + const park = run("park-prepublish", configPath, snapshotPath); + expect(park.status).toBe(1); + expect(park.stderr).toContain("plugins.allow must be an array"); + expect(readFileSync(configPath, "utf8")).toBe(authoredConfig); + expect(existsSync(snapshotPath)).toBe(false); + }); + + it("keeps the snapshot when restore cannot replace the config path", () => { + const root = tempDirs.make("openclaw-failed-config-restore-"); + const configPath = path.join(root, "config-directory"); + const snapshotPath = path.join(root, "openclaw.authored.json"); + mkdirSync(configPath); + writeFileSync(snapshotPath, '{"gateway":{"mode":"local"}}\n'); + + const restore = run("restore", configPath, snapshotPath); + expect(restore.status).toBe(1); + expect(existsSync(snapshotPath)).toBe(true); + }); +}); diff --git a/test/scripts/upgrade-survivor-plugin-registry.test.ts b/test/scripts/upgrade-survivor-plugin-registry.test.ts index 3b557c86e7d5..bc834ca769d4 100644 --- a/test/scripts/upgrade-survivor-plugin-registry.test.ts +++ b/test/scripts/upgrade-survivor-plugin-registry.test.ts @@ -43,7 +43,7 @@ fi printf '%s\n' "$*" >>"$CAPTURE_DIR/node-args" printf '%s|%s|%s\n' \ "$OPENCLAW_DOCKER_ALL_LANES" \ - "$OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS" \ + "\${OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS:-}" \ "$OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS" >>"$CAPTURE_DIR/node-env" mkdir -p "$OPENCLAW_DOCKER_ALL_LOG_DIR/prepublish-plugin-registry" printf '%s' "$REGISTRY_MANIFEST" \ @@ -92,6 +92,36 @@ done } describe("standalone upgrade survivor plugin registry", () => { + it("prepares and mounts the direct auto-auth planner registry", () => { + const { captureDir, result } = runSurvivor({ + OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC: undefined, + OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE: "0", + OPENCLAW_UPGRADE_SURVIVOR_UPDATE_RESTART_MODE: "auto-auth", + }); + + expect(result.status, result.stderr).toBe(0); + expect(readFileSync(join(captureDir, "node-env"), "utf8")).toBe("update-restart-auth||base\n"); + }); + + it("preserves an explicitly supplied direct registry", () => { + const registryDir = tempDirs.make("openclaw-direct-plugin-registry-"); + const manifestPath = join(registryDir, "prepublish-plugin-registry.json"); + writeFileSync(manifestPath, registryManifest()); + + const { captureDir, result } = runSurvivor({ + OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR: registryDir, + OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_MANIFEST_SHA256: createHash("sha256") + .update(readFileSync(manifestPath)) + .digest("hex"), + OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC: undefined, + OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE: "0", + OPENCLAW_UPGRADE_SURVIVOR_UPDATE_RESTART_MODE: "auto-auth", + }); + + expect(result.status, result.stderr).toBe(0); + expect(existsSync(join(captureDir, "node-args"))).toBe(false); + }); + it("prepares and mounts a planner-owned registry for the current candidate", () => { const { captureDir, result } = runSurvivor({ OPENCLAW_UPGRADE_SURVIVOR_SCENARIO: "configured-plugin-installs",