fix(update): preserve plugin convergence through package restart (#131062)

* fix(update): preserve plugin convergence during package upgrades

* fix(e2e): isolate upgrade restart auth setup

* fix(e2e): isolate candidate restart config

* fix(doctor): skip repairs for disabled plugins

* fix(e2e): use canonical identity for restart install

* fix(update): release plugin lease before doctor

* fix(e2e): seed upgrade companion installs

* fix(e2e): validate companion install versions

* fix(ci): register upgrade config parking script

* test(plugins): type npm install config fixture
This commit is contained in:
Vincent Koc
2026-08-28 03:26:43 +08:00
committed by GitHub
parent b9d01e7127
commit 30aa2794d9
26 changed files with 1863 additions and 448 deletions
@@ -165,64 +165,6 @@ describe("ClawHub fixture server", () => {
expect(emptyAssertion.stderr).toContain("assert-no-requests requires <base-url>");
});
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-");
+304 -29
View File
@@ -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", () => {
@@ -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();
}
});
});
@@ -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<string, Record<string, unknown>>,
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<string, Record<string, unknown>> = {
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();
});
@@ -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);
});
});
@@ -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",