mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
510 lines
19 KiB
TypeScript
510 lines
19 KiB
TypeScript
// Plugin Update Unchanged Docker tests cover plugin update unchanged docker script behavior.
|
|
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { pathToFileURL } from "node:url";
|
|
import { describe, expect, it } from "vitest";
|
|
import { loadInstalledPluginIndex } from "../../src/plugins/installed-plugin-index.js";
|
|
import { createInstalledPluginOwnershipResolver } from "../../src/plugins/installed-plugin-package-ownership.js";
|
|
import { closeOpenClawStateDatabaseByPath } from "../../src/state/openclaw-state-db-cache.js";
|
|
import { openOpenClawStateDatabase } from "../../src/state/openclaw-state-db.js";
|
|
|
|
const PLUGIN_UPDATE_SCENARIO_SCRIPT = "scripts/e2e/lib/plugin-update/unchanged-scenario.sh";
|
|
const CORRUPT_UPDATE_SCENARIO_SCRIPT = "scripts/e2e/lib/plugin-update/corrupt-update-scenario.sh";
|
|
const CORRUPT_UPDATE_DOCKER_SCRIPT = "scripts/e2e/update-corrupt-plugin-docker.sh";
|
|
const PLUGIN_UPDATE_PROBE_SCRIPT = "scripts/e2e/lib/plugin-update/probe.mjs";
|
|
const PLUGIN_UPDATE_REGISTRY_SCRIPT = "scripts/e2e/lib/plugin-update/registry-server.mjs";
|
|
const CORRUPT_PLUGIN_ID = "demo-corrupt-plugin";
|
|
const PLUGIN_INDEX_MODULE_URL = pathToFileURL(
|
|
path.resolve("scripts/e2e/lib/plugin-index-sqlite.mjs"),
|
|
).href;
|
|
|
|
function seedInstallState(root: string, initialized: boolean) {
|
|
const stateDir = path.join(root, ".openclaw");
|
|
const configPath = path.join(stateDir, "openclaw.json");
|
|
const env = {
|
|
...process.env,
|
|
HOME: root,
|
|
OPENCLAW_CONFIG_PATH: configPath,
|
|
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
|
|
OPENCLAW_STATE_DIR: stateDir,
|
|
OPENCLAW_VERSION: "2026.8.1",
|
|
VITEST: "true",
|
|
};
|
|
if (initialized) {
|
|
const database = openOpenClawStateDatabase({ env });
|
|
closeOpenClawStateDatabaseByPath(database.path);
|
|
}
|
|
execFileSync("node", [PLUGIN_UPDATE_PROBE_SCRIPT, "seed"], {
|
|
encoding: "utf8",
|
|
env,
|
|
stdio: "pipe",
|
|
});
|
|
return { configPath, env, stateDir };
|
|
}
|
|
|
|
function runProbe(command: string, payload: unknown): void {
|
|
const root = mkdtempSync(path.join(tmpdir(), "openclaw-plugin-update-probe-"));
|
|
const payloadPath = path.join(root, "payload.json");
|
|
try {
|
|
writeFileSync(payloadPath, `${JSON.stringify(payload, null, 2)}\n`);
|
|
execFileSync("node", [PLUGIN_UPDATE_PROBE_SCRIPT, command, payloadPath, CORRUPT_PLUGIN_ID], {
|
|
encoding: "utf8",
|
|
stdio: "pipe",
|
|
});
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function runProbeStatus(
|
|
command: string,
|
|
payload: unknown,
|
|
): { status: number | null; stderr: string } {
|
|
const root = mkdtempSync(path.join(tmpdir(), "openclaw-plugin-update-probe-"));
|
|
const payloadPath = path.join(root, "payload.json");
|
|
try {
|
|
writeFileSync(payloadPath, `${JSON.stringify(payload, null, 2)}\n`);
|
|
const result = spawnSync(
|
|
"node",
|
|
[PLUGIN_UPDATE_PROBE_SCRIPT, command, payloadPath, CORRUPT_PLUGIN_ID],
|
|
{
|
|
encoding: "utf8",
|
|
stdio: "pipe",
|
|
},
|
|
);
|
|
return { status: result.status, stderr: result.stderr };
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function corruptPolicyConfig(
|
|
allow: unknown,
|
|
codexEnabled = false,
|
|
corruptEntry = { enabled: false },
|
|
) {
|
|
return {
|
|
plugins: {
|
|
allow,
|
|
entries: { [CORRUPT_PLUGIN_ID]: corruptEntry, codex: { enabled: codexEnabled } },
|
|
},
|
|
};
|
|
}
|
|
|
|
function runProbeFileStatus(
|
|
command: string,
|
|
filePath: string,
|
|
): { status: number | null; stderr: string } {
|
|
const result = spawnSync("node", [PLUGIN_UPDATE_PROBE_SCRIPT, command, filePath], {
|
|
encoding: "utf8",
|
|
stdio: "pipe",
|
|
});
|
|
return { status: result.status, stderr: result.stderr };
|
|
}
|
|
|
|
function runCorruptUpdateDockerBaseline(env: Record<string, string>) {
|
|
const root = mkdtempSync(path.join(tmpdir(), "openclaw-corrupt-update-docker-"));
|
|
const binDir = path.join(root, "bin");
|
|
const dockerArgsPath = path.join(root, "docker-args");
|
|
const packagePath = path.join(root, "candidate.tgz");
|
|
try {
|
|
mkdirSync(binDir);
|
|
writeFileSync(packagePath, "fake package");
|
|
writeFileSync(
|
|
path.join(binDir, "docker"),
|
|
`#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
if [[ "$1" == "run" ]]; then
|
|
printf '%s\n' "$@" > "$DOCKER_ARGS_PATH"
|
|
fi
|
|
`,
|
|
{ mode: 0o755 },
|
|
);
|
|
const result = spawnSync("bash", [CORRUPT_UPDATE_DOCKER_SCRIPT], {
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
DOCKER_ARGS_PATH: dockerArgsPath,
|
|
OPENCLAW_CURRENT_PACKAGE_TGZ: packagePath,
|
|
OPENCLAW_SKIP_DOCKER_BUILD: "1",
|
|
PATH: `${binDir}:${process.env.PATH ?? ""}`,
|
|
...env,
|
|
},
|
|
});
|
|
const dockerArgs = existsSync(dockerArgsPath) ? readFileSync(dockerArgsPath, "utf8") : "";
|
|
return {
|
|
baseline: dockerArgs
|
|
.split("\n")
|
|
.find((entry) => entry.startsWith("OPENCLAW_UPDATE_CORRUPT_PLUGIN_BASELINE=")),
|
|
result,
|
|
};
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
async function waitForPortFile(portFile: string): Promise<number> {
|
|
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
if (existsSync(portFile)) {
|
|
const port = Number.parseInt(readFileSync(portFile, "utf8"), 10);
|
|
if (Number.isInteger(port) && port > 0) {
|
|
return port;
|
|
}
|
|
}
|
|
await delay(50);
|
|
}
|
|
throw new Error("registry did not write a port file");
|
|
}
|
|
|
|
describe("plugin update unchanged Docker E2E", () => {
|
|
it.each([false, true])(
|
|
"seeds plugin ownership with initialized state=%s",
|
|
async (initialized) => {
|
|
const root = mkdtempSync(path.join(tmpdir(), "openclaw-plugin-update-seed-"));
|
|
try {
|
|
const { configPath, env, stateDir } = seedInstallState(root, initialized);
|
|
const config = JSON.parse(readFileSync(configPath, "utf8")) as {
|
|
plugins?: Record<string, unknown>;
|
|
};
|
|
expect(config).toEqual({ plugins: {} });
|
|
expect(
|
|
JSON.parse(
|
|
execFileSync("node", [PLUGIN_UPDATE_PROBE_SCRIPT, "snapshot"], {
|
|
encoding: "utf8",
|
|
env,
|
|
}),
|
|
),
|
|
).toMatchObject({ source: "npm", resolvedVersion: "0.9.0" });
|
|
|
|
const { readPluginInstallIndex } = await import(PLUGIN_INDEX_MODULE_URL);
|
|
const persisted = readPluginInstallIndex({ configPath, stateDir });
|
|
expect(persisted.installRecords).toMatchObject({
|
|
"lossless-claw": {
|
|
source: "npm",
|
|
installPath: "~/.openclaw/extensions/lossless-claw",
|
|
},
|
|
});
|
|
expect(persisted.plugins).toEqual([
|
|
expect.objectContaining({
|
|
pluginId: "lossless-claw",
|
|
installOwner: "lossless-claw",
|
|
rootDir: path.join(stateDir, "extensions", "lossless-claw"),
|
|
}),
|
|
]);
|
|
|
|
const database = openOpenClawStateDatabase({ env });
|
|
closeOpenClawStateDatabaseByPath(database.path);
|
|
const liveIndex = loadInstalledPluginIndex({
|
|
config,
|
|
env,
|
|
stateDir,
|
|
});
|
|
expect(
|
|
createInstalledPluginOwnershipResolver(liveIndex, env).resolvePackage("lossless-claw"),
|
|
).toMatchObject({
|
|
ok: true,
|
|
value: {
|
|
installOwner: "lossless-claw",
|
|
pluginIds: ["lossless-claw"],
|
|
},
|
|
});
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it("bounds the update command and prints diagnostics on hangs", () => {
|
|
const script = readFileSync(PLUGIN_UPDATE_SCENARIO_SCRIPT, "utf8");
|
|
|
|
expect(script).toContain("OPENCLAW_PLUGIN_UPDATE_TIMEOUT_SECONDS");
|
|
expect(script).toContain("registry_port_file=/tmp/openclaw-e2e-registry.port");
|
|
expect(script).toContain(
|
|
'node scripts/e2e/lib/plugin-update/registry-server.mjs "$registry_port_file"',
|
|
);
|
|
expect(script).toContain(
|
|
'export NPM_CONFIG_REGISTRY="http://127.0.0.1:$(cat "$registry_port_file")"',
|
|
);
|
|
expect(script).toContain('export npm_config_registry="$NPM_CONFIG_REGISTRY"');
|
|
expect(script).toContain(
|
|
"openclaw_e2e_read_positive_int_env OPENCLAW_PLUGIN_UPDATE_TIMEOUT_SECONDS 180",
|
|
);
|
|
expect(script).toContain(
|
|
'openclaw_e2e_maybe_timeout "${plugin_update_timeout_seconds}s" node "$entry" plugins update',
|
|
);
|
|
expect(script).not.toContain(
|
|
'plugin_update_timeout_seconds="${OPENCLAW_PLUGIN_UPDATE_TIMEOUT_SECONDS:-180}"',
|
|
);
|
|
expect(script).not.toMatch(
|
|
/^\s*timeout "\$\{plugin_update_timeout_seconds\}s" node "\$entry"/mu,
|
|
);
|
|
expect(script).toContain('"--- plugin update output ---"');
|
|
expect(script).toContain('"--- local registry output ---"');
|
|
expect(script).toContain("openclaw_e2e_print_log /tmp/plugin-update-output.log");
|
|
expect(script).toContain("openclaw_e2e_print_log /tmp/openclaw-e2e-registry.log");
|
|
expect(script).not.toContain("cat /tmp/plugin-update-output.log");
|
|
expect(script).not.toContain("cat /tmp/openclaw-e2e-registry.log");
|
|
});
|
|
|
|
it("serves plugin metadata from an ephemeral registry port", async () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), "openclaw-plugin-update-registry-"));
|
|
const portFile = path.join(root, "registry.port");
|
|
const child = spawn("node", [PLUGIN_UPDATE_REGISTRY_SCRIPT, portFile], {
|
|
stdio: "ignore",
|
|
});
|
|
try {
|
|
const port = await waitForPortFile(portFile);
|
|
|
|
const response = await fetch(`http://127.0.0.1:${port}/@example%2flossless-claw`);
|
|
expect(response.status).toBe(200);
|
|
const metadata = (await response.json()) as {
|
|
versions?: Record<string, { dist?: { tarball?: string } }>;
|
|
};
|
|
expect(metadata.versions?.["0.9.0"]?.dist?.tarball).toBe(
|
|
`http://127.0.0.1:${port}/@example/lossless-claw/-/lossless-claw-0.9.0.tgz`,
|
|
);
|
|
} finally {
|
|
child.kill("SIGTERM");
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("bounds assert-output diagnostics to the saved command log tail", () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), "openclaw-plugin-update-probe-"));
|
|
const logPath = path.join(root, "plugin-update-output.log");
|
|
try {
|
|
writeFileSync(
|
|
logPath,
|
|
`DO_NOT_PRINT_OLD_PLUGIN_UPDATE_LOG\n${"filler line\n".repeat(12 * 1024)}missing marker tail`,
|
|
"utf8",
|
|
);
|
|
|
|
const result = runProbeFileStatus("assert-output", logPath);
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("Expected up-to-date output missing");
|
|
expect(result.stderr).toContain("Output tail:");
|
|
expect(result.stderr).toContain("missing marker tail");
|
|
expect(result.stderr).not.toContain("DO_NOT_PRINT_OLD_PLUGIN_UPDATE_LOG");
|
|
expect(result.stderr.length).toBeLessThan(80 * 1024);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("detects unexpected download output before a large log tail", () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), "openclaw-plugin-update-probe-"));
|
|
const logPath = path.join(root, "plugin-update-output.log");
|
|
try {
|
|
writeFileSync(
|
|
logPath,
|
|
[
|
|
"Downloading @example/lossless-claw",
|
|
"filler line\n".repeat(12 * 1024),
|
|
"lossless-claw is up to date (0.9.0).",
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
|
|
const result = runProbeFileStatus("assert-output", logPath);
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("Unexpected npm download/reinstall path");
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("waits for the local registry process during cleanup", () => {
|
|
const script = readFileSync(PLUGIN_UPDATE_SCENARIO_SCRIPT, "utf8");
|
|
|
|
expect(script).toContain('openclaw_e2e_stop_process "${registry_pid:-}"');
|
|
expect(script).not.toContain('kill "$registry_pid"');
|
|
});
|
|
|
|
it("bounds corrupt plugin update commands and prints diagnostics on hangs", () => {
|
|
const script = readFileSync(CORRUPT_UPDATE_SCENARIO_SCRIPT, "utf8");
|
|
const nonCodexRoute =
|
|
'node "$entry" config set agents.defaults.model anthropic/claude-sonnet-4-6 >/dev/null';
|
|
const codexOptOut = 'node "$entry" config set plugins.entries.codex.enabled false >/dev/null';
|
|
|
|
expect(script).toContain('plugins install "npm:@openclaw/demo-corrupt-plugin@0.0.1" --force');
|
|
expect(script).toContain("config set plugins.allow '[\"demo-corrupt-plugin\"]'");
|
|
expect(script).toContain(nonCodexRoute);
|
|
expect(script.indexOf(nonCodexRoute)).toBeLessThan(script.indexOf(codexOptOut));
|
|
expect(script).toContain("OPENCLAW_UPDATE_CORRUPT_PLUGIN_TIMEOUT_SECONDS");
|
|
expect(script).toContain(
|
|
"openclaw_e2e_read_positive_int_env OPENCLAW_UPDATE_CORRUPT_PLUGIN_TIMEOUT_SECONDS 900",
|
|
);
|
|
expect(script).toContain("OPENCLAW_UPDATE_CORRUPT_PLUGIN_STEP_TIMEOUT_SECONDS");
|
|
expect(script).toContain(
|
|
"default_update_step_timeout_seconds=$((10#$update_timeout_seconds - 30))",
|
|
);
|
|
expect(script).not.toContain(
|
|
'update_timeout_seconds="${OPENCLAW_UPDATE_CORRUPT_PLUGIN_TIMEOUT_SECONDS:-900}"',
|
|
);
|
|
expect(
|
|
script.match(/openclaw_e2e_maybe_timeout "\$\{update_timeout_seconds\}s" \\/gu)?.length,
|
|
).toBe(1);
|
|
expect(script).toContain("--channel beta");
|
|
expect(script.match(/--timeout "\$update_step_timeout_seconds"/g)).toHaveLength(1);
|
|
expect(script).not.toContain("OPENCLAW_UPDATE_POST_CORE=1");
|
|
expect(script).not.toContain(
|
|
'node "$entry" update --channel beta --tag "${OPENCLAW_CURRENT_PACKAGE_TGZ',
|
|
);
|
|
expect(script).toContain(
|
|
'OPENCLAW_NPM_REGISTRY_UPSTREAM="${OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_URL:-https://registry.npmjs.org/}"',
|
|
);
|
|
expect(script).toContain(
|
|
"openclaw update failed or timed out after ${update_timeout_seconds}s",
|
|
);
|
|
expect(script.match(/openclaw_e2e_print_log \/tmp\/openclaw-update-corrupt-/g)).toHaveLength(5);
|
|
expect(script).not.toContain("cat /tmp/openclaw-update-corrupt-");
|
|
});
|
|
|
|
it.each(["2026.9.2", "2026.8.2"])(
|
|
"keeps a historical %s override out of the same-schema repair lane",
|
|
(version) => {
|
|
const result = runCorruptUpdateDockerBaseline({
|
|
OPENCLAW_UPDATE_CORRUPT_PLUGIN_BASELINE: `openclaw@${version}`,
|
|
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC: `openclaw@${version}`,
|
|
});
|
|
expect(result.result.status, result.result.stderr).toBe(0);
|
|
expect(result.baseline).toBeUndefined();
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["explicit disable", { enabled: false }, [CORRUPT_PLUGIN_ID]],
|
|
["typed quarantine", { enabled: true }, [CORRUPT_PLUGIN_ID]],
|
|
["target-owned additions", { enabled: false }, [CORRUPT_PLUGIN_ID, "memory-core", "codex"]],
|
|
])("preserves the explicit allow policy after %s recovery", (_recovery, entry, allow) => {
|
|
expect(() =>
|
|
runProbe("assert-corrupt-policy-preserved", corruptPolicyConfig(allow, false, entry)),
|
|
).not.toThrow();
|
|
});
|
|
|
|
it.each([
|
|
["non-array allow policy", CORRUPT_PLUGIN_ID, false, "plugins.allow to be an array"],
|
|
["missing fixture membership", ["memory-core"], false, "exactly once"],
|
|
["duplicate fixture membership", [CORRUPT_PLUGIN_ID, CORRUPT_PLUGIN_ID], false, "exactly once"],
|
|
[
|
|
"loss of the Codex opt-out",
|
|
[CORRUPT_PLUGIN_ID],
|
|
true,
|
|
"explicit Codex opt-out to survive, got true",
|
|
],
|
|
])("rejects corrupt update recovery with %s", (_case, allow, codexEnabled, expectedError) => {
|
|
const result = runProbeStatus(
|
|
"assert-corrupt-policy-preserved",
|
|
corruptPolicyConfig(allow, codexEnabled),
|
|
);
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain(expectedError);
|
|
});
|
|
|
|
it.each([
|
|
"warning",
|
|
"core failure",
|
|
"core skipped",
|
|
"missing warning",
|
|
"wrong plugin",
|
|
"missing guidance",
|
|
"unsafe recovery",
|
|
] as const)(
|
|
"requires a successful core update and named unavailable-plugin notice: %s",
|
|
(outcome) => {
|
|
const warnedPluginId = outcome === "wrong plugin" ? "another-plugin" : CORRUPT_PLUGIN_ID;
|
|
const result = runProbeStatus("assert-corrupt-unavailable", {
|
|
status:
|
|
outcome === "core failure" ? "error" : outcome === "core skipped" ? "skipped" : "ok",
|
|
...(outcome === "unsafe recovery" ? { recovery: { serviceRestartSafe: false } } : {}),
|
|
postUpdate: {
|
|
plugins: {
|
|
status: "warning",
|
|
warnings:
|
|
outcome === "missing warning"
|
|
? []
|
|
: [
|
|
{
|
|
pluginId: warnedPluginId,
|
|
reason: "package.json is missing",
|
|
message: `Plugin "${warnedPluginId}" could not be loaded. Run \`openclaw doctor --fix\` to check and repair the load problem.`,
|
|
guidance: outcome === "missing guidance" ? [] : ["openclaw doctor --fix"],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
expect(result.status).toBe(outcome === "warning" ? 0 : 1);
|
|
if (outcome !== "warning") {
|
|
expect(result.stderr).toContain(
|
|
"expected successful core update with a named plugin repair notice",
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each(["clean", "updated", "unchanged", "repaired after error", "unrelated warning"])(
|
|
"accepts completed corrupt plugin repair: %s",
|
|
(outcome) => {
|
|
expect(() =>
|
|
runProbe("assert-corrupt-plugin-result", {
|
|
status: outcome === "unrelated warning" ? "warning" : "ok",
|
|
npm: {
|
|
outcomes:
|
|
outcome === "clean"
|
|
? []
|
|
: [
|
|
...(outcome === "repaired after error"
|
|
? [{ pluginId: CORRUPT_PLUGIN_ID, status: "error" }]
|
|
: []),
|
|
{
|
|
pluginId: CORRUPT_PLUGIN_ID,
|
|
status: outcome === "unchanged" ? "unchanged" : "updated",
|
|
},
|
|
],
|
|
},
|
|
warnings:
|
|
outcome === "unrelated warning"
|
|
? [{ pluginId: "another-plugin", message: "Retry another plugin." }]
|
|
: [],
|
|
}),
|
|
).not.toThrow();
|
|
},
|
|
);
|
|
|
|
it.each(["disabled", "quarantined", "still missing"])(
|
|
"rejects unresolved corrupt plugin repair: %s",
|
|
(outcome) => {
|
|
const result = runProbeStatus("assert-corrupt-plugin-result", {
|
|
status: "warning",
|
|
npm: {
|
|
outcomes: [
|
|
{
|
|
pluginId: CORRUPT_PLUGIN_ID,
|
|
status:
|
|
outcome === "disabled"
|
|
? "skipped"
|
|
: outcome === "quarantined"
|
|
? "error"
|
|
: "updated",
|
|
},
|
|
],
|
|
},
|
|
warnings:
|
|
outcome === "still missing"
|
|
? [{ pluginId: CORRUPT_PLUGIN_ID, reason: "package.json is missing" }]
|
|
: [],
|
|
});
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain(
|
|
`expected ${CORRUPT_PLUGIN_ID} restored without unresolved plugin errors or warnings`,
|
|
);
|
|
},
|
|
);
|
|
});
|