mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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:
@@ -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-");
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user