Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
671
scripts/e2e/lib/upgrade-survivor/assertions.mjs
Normal file
671
scripts/e2e/lib/upgrade-survivor/assertions.mjs
Normal file
@@ -0,0 +1,671 @@
|
||||
// Assertions for upgrade-survivor E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { readPluginInstallIndex } from "../plugin-index-sqlite.mjs";
|
||||
|
||||
const command = process.argv[2];
|
||||
const SCENARIOS = new Set([
|
||||
"base",
|
||||
"acpx-openclaw-tools-bridge",
|
||||
"feishu-channel",
|
||||
"bootstrap-persona",
|
||||
"channel-post-core-restore",
|
||||
"plugin-deps-cleanup",
|
||||
"configured-plugin-installs",
|
||||
"stale-source-plugin-shadow",
|
||||
"tilde-log-path",
|
||||
"versioned-runtime-deps",
|
||||
]);
|
||||
|
||||
const PERSONA_FILES = new Map([
|
||||
["BOOTSTRAP.md", "# Existing Bootstrap\n\nDo not overwrite me during update.\n"],
|
||||
["SOUL.md", "# Existing Soul\n\nKeep this voice intact.\n"],
|
||||
["USER.md", "# Existing User\n\nPrefers survivor tests.\n"],
|
||||
["MEMORY.md", "# Existing Memory\n\nUpgrade reports came from real users.\n"],
|
||||
]);
|
||||
|
||||
const LEGACY_SESSION_MAIN_ID = "upgrade-main-session";
|
||||
const LEGACY_SESSION_DIRECT_ID = "upgrade-direct-session";
|
||||
const LEGACY_SESSION_GROUP_ID = "upgrade-group-session";
|
||||
|
||||
function requireEnv(name) {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readJson(file) {
|
||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
}
|
||||
|
||||
function resolveHomePath(value) {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
return "";
|
||||
}
|
||||
if (value === "~") {
|
||||
return process.env.HOME || value;
|
||||
}
|
||||
if (value.startsWith("~/")) {
|
||||
return path.join(process.env.HOME || "", value.slice(2));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isPathInside(parent, child) {
|
||||
const relative = path.relative(parent, child);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function isPathInsideManagedNpmProjectPackageRoot(params) {
|
||||
const relative = path.relative(path.join(params.stateDir, "npm", "projects"), params.installPath);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
return false;
|
||||
}
|
||||
const segments = relative.split(path.sep);
|
||||
const packageSegments = params.packageName.split("/");
|
||||
return (
|
||||
segments.length === 2 + packageSegments.length &&
|
||||
Boolean(segments[0]) &&
|
||||
segments[1] === "node_modules" &&
|
||||
packageSegments.every((segment, index) => segments[index + 2] === segment)
|
||||
);
|
||||
}
|
||||
|
||||
function write(file, contents) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, contents);
|
||||
}
|
||||
|
||||
function writeJson(file, value) {
|
||||
write(file, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function seedLegacySessionMetadata(stateDir) {
|
||||
const legacySessionsDir = path.join(stateDir, "sessions");
|
||||
writeJson(path.join(legacySessionsDir, "sessions.json"), {
|
||||
main: {
|
||||
sessionId: LEGACY_SESSION_MAIN_ID,
|
||||
sessionFile: path.join(legacySessionsDir, `${LEGACY_SESSION_MAIN_ID}.jsonl`),
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
updatedAt: 1710000000000,
|
||||
skillsSnapshot: {
|
||||
prompt: "legacy prompt survives as metadata",
|
||||
resolvedSkills: [
|
||||
{
|
||||
name: "legacy-heavy-skill-cache",
|
||||
filePath: "/tmp/openclaw-old-package/skills/legacy-heavy-skill-cache/SKILL.md",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"+15551234567": {
|
||||
sessionId: LEGACY_SESSION_DIRECT_ID,
|
||||
sessionFile: path.join(legacySessionsDir, `${LEGACY_SESSION_DIRECT_ID}.jsonl`),
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
updatedAt: 1710000000100,
|
||||
},
|
||||
"slack:channel:CUPGRADE": {
|
||||
sessionId: LEGACY_SESSION_GROUP_ID,
|
||||
sessionFile: path.join(legacySessionsDir, `${LEGACY_SESSION_GROUP_ID}.jsonl`),
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
updatedAt: 1710000000200,
|
||||
lastChannel: "slack",
|
||||
lastTo: "CUPGRADE",
|
||||
},
|
||||
});
|
||||
for (const sessionId of [
|
||||
LEGACY_SESSION_MAIN_ID,
|
||||
LEGACY_SESSION_DIRECT_ID,
|
||||
LEGACY_SESSION_GROUP_ID,
|
||||
]) {
|
||||
write(
|
||||
path.join(legacySessionsDir, `${sessionId}.jsonl`),
|
||||
`${JSON.stringify({ type: "session", id: sessionId })}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getScenario() {
|
||||
const scenario = process.env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIO || "base";
|
||||
assert(SCENARIOS.has(scenario), `unknown upgrade survivor scenario: ${scenario}`);
|
||||
return scenario;
|
||||
}
|
||||
|
||||
function getConfig() {
|
||||
return readJson(requireEnv("OPENCLAW_CONFIG_PATH"));
|
||||
}
|
||||
|
||||
function getCoverage() {
|
||||
const file = process.env.OPENCLAW_UPGRADE_SURVIVOR_CONFIG_COVERAGE_JSON;
|
||||
if (!file || !fs.existsSync(file)) {
|
||||
return null;
|
||||
}
|
||||
return readJson(file);
|
||||
}
|
||||
|
||||
function acceptsIntent(coverage, id) {
|
||||
if (!coverage) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
Array.isArray(coverage.acceptedIntents) &&
|
||||
coverage.acceptedIntents.includes(id) &&
|
||||
!coverage.skippedIntents?.includes(id)
|
||||
);
|
||||
}
|
||||
|
||||
function hasCoverage(coverage) {
|
||||
return Boolean(coverage);
|
||||
}
|
||||
|
||||
function seedState() {
|
||||
const stateDir = requireEnv("OPENCLAW_STATE_DIR");
|
||||
const workspace = requireEnv("OPENCLAW_TEST_WORKSPACE_DIR");
|
||||
const scenario = getScenario();
|
||||
|
||||
write(
|
||||
path.join(workspace, "IDENTITY.md"),
|
||||
"# Upgrade Survivor\n\nThis workspace must survive package update and doctor repair.\n",
|
||||
);
|
||||
if (scenario === "bootstrap-persona") {
|
||||
for (const [fileName, contents] of PERSONA_FILES) {
|
||||
write(path.join(workspace, fileName), contents);
|
||||
}
|
||||
}
|
||||
writeJson(path.join(workspace, ".openclaw", "workspace-state.json"), {
|
||||
version: 1,
|
||||
setupCompletedAt: "2026-04-01T00:00:00.000Z",
|
||||
});
|
||||
writeJson(path.join(stateDir, "agents", "main", "sessions", "legacy-session.json"), {
|
||||
id: "legacy-session",
|
||||
agentId: "main",
|
||||
title: "Existing user session",
|
||||
});
|
||||
seedLegacySessionMetadata(stateDir);
|
||||
|
||||
const runtimeRoot = path.join(stateDir, "plugin-runtime-deps");
|
||||
for (const plugin of ["discord", "telegram", "whatsapp"]) {
|
||||
writeJson(path.join(runtimeRoot, plugin, ".openclaw-runtime-deps-stamp.json"), {
|
||||
version: 0,
|
||||
plugin,
|
||||
stale: true,
|
||||
});
|
||||
write(
|
||||
path.join(
|
||||
runtimeRoot,
|
||||
plugin,
|
||||
".openclaw-runtime-deps-copy-stale",
|
||||
"node_modules",
|
||||
"stale-sentinel",
|
||||
"package.json",
|
||||
),
|
||||
`${JSON.stringify({ name: "stale-sentinel", version: "0.0.0" }, null, 2)}\n`,
|
||||
);
|
||||
}
|
||||
if (scenario === "versioned-runtime-deps") {
|
||||
const version = process.env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_VERSION || "2026.4.24";
|
||||
for (const plugin of ["discord", "feishu", "telegram", "whatsapp"]) {
|
||||
writeJson(
|
||||
path.join(
|
||||
runtimeRoot,
|
||||
`openclaw-${version}-${plugin}`,
|
||||
".openclaw-runtime-deps-stamp.json",
|
||||
),
|
||||
{
|
||||
packageVersion: version,
|
||||
plugin,
|
||||
stale: true,
|
||||
},
|
||||
);
|
||||
write(
|
||||
path.join(
|
||||
runtimeRoot,
|
||||
`openclaw-${version}-${plugin}`,
|
||||
"node_modules",
|
||||
"stale-sentinel",
|
||||
"package.json",
|
||||
),
|
||||
`${JSON.stringify({ name: "stale-sentinel", version: "0.0.0" }, null, 2)}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
writeJson(path.join(stateDir, "survivor-baseline.json"), {
|
||||
agents: ["main", "ops"],
|
||||
discordGuild: "222222222222222222",
|
||||
discordChannel: "333333333333333333",
|
||||
telegramGroup: "-1001234567890",
|
||||
whatsappGroup: "120363000000000000@g.us",
|
||||
workspaceIdentity: path.join(workspace, "IDENTITY.md"),
|
||||
scenario,
|
||||
});
|
||||
}
|
||||
|
||||
function assertConfigSurvived() {
|
||||
const config = getConfig();
|
||||
const coverage = getCoverage();
|
||||
|
||||
if (acceptsIntent(coverage, "update")) {
|
||||
assert(config.update?.channel === "stable", "update.channel was not preserved");
|
||||
}
|
||||
if (acceptsIntent(coverage, "gateway")) {
|
||||
assert(config.gateway?.auth?.mode === "token", "gateway auth mode was not preserved");
|
||||
}
|
||||
|
||||
if (acceptsIntent(coverage, "models")) {
|
||||
assert(config.models?.providers?.openai, "OpenAI model provider missing");
|
||||
}
|
||||
|
||||
if (acceptsIntent(coverage, "agents")) {
|
||||
const agents = config.agents?.list ?? [];
|
||||
assert(Array.isArray(agents), "agents.list missing after update/doctor");
|
||||
assert(
|
||||
agents.some((agent) => agent?.id === "main"),
|
||||
"main agent missing",
|
||||
);
|
||||
assert(
|
||||
agents.some((agent) => agent?.id === "ops"),
|
||||
"ops agent missing",
|
||||
);
|
||||
if (hasCoverage(coverage)) {
|
||||
assert(config.agents?.defaults?.contextTokens === 64000, "default contextTokens changed");
|
||||
} else {
|
||||
assert(
|
||||
agents.find((agent) => agent?.id === "main")?.contextTokens === 64000,
|
||||
"main agent contextTokens changed",
|
||||
);
|
||||
}
|
||||
if (!hasCoverage(coverage) || !coverage.skippedIntents?.includes("agent-modern-preferences")) {
|
||||
assert(
|
||||
agents.find((agent) => agent?.id === "ops")?.fastModeDefault === true,
|
||||
"ops fastModeDefault changed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (acceptsIntent(coverage, "skills")) {
|
||||
assert(config.skills?.allowBundled?.includes("memory"), "memory skill allowlist changed");
|
||||
}
|
||||
|
||||
if (acceptsIntent(coverage, "plugins")) {
|
||||
const pluginAllow = config.plugins?.allow ?? [];
|
||||
assert(pluginAllow.includes("discord"), "discord plugin allow entry missing");
|
||||
assert(pluginAllow.includes("telegram"), "telegram plugin allow entry missing");
|
||||
if (getScenario() === "configured-plugin-installs") {
|
||||
assert(pluginAllow.includes("matrix"), "matrix plugin allow entry missing");
|
||||
} else {
|
||||
assert(pluginAllow.includes("whatsapp"), "whatsapp plugin allow entry missing");
|
||||
}
|
||||
if (hasCoverage(coverage) && acceptsIntent(coverage, "feishu-channel")) {
|
||||
assert(pluginAllow.includes("feishu"), "feishu plugin allow entry missing");
|
||||
}
|
||||
}
|
||||
|
||||
if (hasCoverage(coverage) && acceptsIntent(coverage, "acpx-openclaw-tools-bridge")) {
|
||||
const pluginAllow = config.plugins?.allow ?? [];
|
||||
assert(pluginAllow.includes("acpx"), "ACPX plugin allow entry missing");
|
||||
assert(config.plugins?.entries?.acpx?.enabled === true, "ACPX plugin entry changed");
|
||||
assert(
|
||||
config.plugins?.entries?.acpx?.config?.openClawToolsMcpBridge === true,
|
||||
"ACPX OpenClaw tools bridge config changed",
|
||||
);
|
||||
}
|
||||
|
||||
if (hasCoverage(coverage) && acceptsIntent(coverage, "configured-plugin-installs")) {
|
||||
const pluginAllow = config.plugins?.allow ?? [];
|
||||
assert(pluginAllow.includes("discord"), "configured install discord allow entry missing");
|
||||
assert(pluginAllow.includes("telegram"), "configured install telegram allow entry missing");
|
||||
assert(pluginAllow.includes("matrix"), "configured install matrix allow entry missing");
|
||||
assert(
|
||||
config.plugins?.entries?.matrix?.enabled === true,
|
||||
"configured install matrix entry changed",
|
||||
);
|
||||
}
|
||||
|
||||
if (acceptsIntent(coverage, "discord-channel")) {
|
||||
const discord = config.channels?.discord;
|
||||
assert(discord?.enabled === true, "discord enabled flag changed");
|
||||
const discordAllowFrom = discord.allowFrom ?? discord.dm?.allowFrom;
|
||||
const discordDmPolicy = discord.dmPolicy ?? discord.dm?.policy;
|
||||
assert(discordDmPolicy === "allowlist", "discord DM policy changed");
|
||||
assert(
|
||||
Array.isArray(discordAllowFrom) && discordAllowFrom.includes("111111111111111111"),
|
||||
"discord allowFrom changed",
|
||||
);
|
||||
assert(
|
||||
discord.guilds?.["222222222222222222"]?.channels?.["333333333333333333"]?.requireMention ===
|
||||
true,
|
||||
"discord guild channel mention policy changed",
|
||||
);
|
||||
assert(discord.threadBindings?.idleHours === 72, "discord thread binding ttl changed");
|
||||
}
|
||||
|
||||
if (acceptsIntent(coverage, "telegram-channel")) {
|
||||
const telegram = config.channels?.telegram;
|
||||
assert(telegram?.enabled === true, "telegram enabled flag changed");
|
||||
assert(
|
||||
telegram.groups?.["-1001234567890"]?.requireMention === true,
|
||||
"telegram group policy changed",
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
acceptsIntent(coverage, "whatsapp-channel") &&
|
||||
getScenario() !== "configured-plugin-installs"
|
||||
) {
|
||||
const whatsapp = config.channels?.whatsapp;
|
||||
assert(whatsapp?.enabled === true, "whatsapp enabled flag changed");
|
||||
const whatsappGroup = whatsapp.groups?.["120363000000000000@g.us"];
|
||||
if (hasCoverage(coverage)) {
|
||||
assert(whatsappGroup?.requireMention === true, "whatsapp group policy changed");
|
||||
} else {
|
||||
assert(
|
||||
whatsappGroup?.systemPrompt === "Use the existing WhatsApp group prompt.",
|
||||
"whatsapp group policy changed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (getScenario() === "channel-post-core-restore") {
|
||||
const whatsapp = config.channels?.whatsapp;
|
||||
assert(whatsapp?.enabled === true, "post-core channel restore dropped WhatsApp");
|
||||
assert(
|
||||
whatsapp.groups?.["120363000000000000@g.us"]?.requireMention === true,
|
||||
"post-core channel restore changed WhatsApp group config",
|
||||
);
|
||||
}
|
||||
|
||||
if (hasCoverage(coverage) && acceptsIntent(coverage, "configured-plugin-installs")) {
|
||||
const matrix = config.channels?.matrix;
|
||||
assert(matrix?.enabled === true, "matrix enabled flag changed");
|
||||
assert(matrix?.homeserver === "https://matrix.example.invalid", "matrix homeserver changed");
|
||||
assert(matrix?.userId === "@upgrade-survivor:matrix.example.invalid", "matrix userId changed");
|
||||
assert(
|
||||
!config.channels?.whatsapp,
|
||||
"whatsapp channel config should be absent in matrix scenario",
|
||||
);
|
||||
}
|
||||
|
||||
if (hasCoverage(coverage) && acceptsIntent(coverage, "feishu-channel")) {
|
||||
const feishu = config.channels?.feishu;
|
||||
assert(feishu?.enabled === true, "feishu enabled flag changed");
|
||||
assert(feishu?.connectionMode === "webhook", "feishu connection mode changed");
|
||||
assert(feishu?.defaultAccount === "default", "feishu default account changed");
|
||||
assert(feishu?.accounts?.default?.appId === "cli_upgrade_survivor", "feishu account changed");
|
||||
assert(
|
||||
feishu.groups?.oc_upgrade_survivor?.requireMention === true,
|
||||
"feishu group mention policy changed",
|
||||
);
|
||||
}
|
||||
|
||||
if (hasCoverage(coverage) && acceptsIntent(coverage, "logging")) {
|
||||
assert(
|
||||
config.logging?.file === "~/openclaw-upgrade-survivor/gateway.jsonl",
|
||||
"logging.file tilde path changed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertStateSurvived() {
|
||||
const stateDir = requireEnv("OPENCLAW_STATE_DIR");
|
||||
const workspace = requireEnv("OPENCLAW_TEST_WORKSPACE_DIR");
|
||||
const scenario = getScenario();
|
||||
const stage = process.env.OPENCLAW_UPGRADE_SURVIVOR_ASSERT_STAGE || "survival";
|
||||
assert(fs.existsSync(path.join(workspace, "IDENTITY.md")), "workspace identity file missing");
|
||||
assert(
|
||||
fs.existsSync(path.join(stateDir, "agents", "main", "sessions", "legacy-session.json")),
|
||||
"legacy session file missing",
|
||||
);
|
||||
if (stage !== "baseline") {
|
||||
assertSessionMetadataMigrated(stateDir);
|
||||
}
|
||||
const legacyRuntimeRoot = path.join(stateDir, "plugin-runtime-deps");
|
||||
if (stage === "baseline") {
|
||||
if (fs.existsSync(legacyRuntimeRoot)) {
|
||||
assert(
|
||||
fs.existsSync(path.join(legacyRuntimeRoot, "discord")),
|
||||
"legacy plugin runtime deps root exists but discord debris is missing before doctor cleanup",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
assert(
|
||||
!fs.existsSync(legacyRuntimeRoot),
|
||||
`legacy plugin runtime deps root survived update/doctor: ${legacyRuntimeRoot}`,
|
||||
);
|
||||
}
|
||||
if (scenario === "bootstrap-persona") {
|
||||
for (const [fileName, contents] of PERSONA_FILES) {
|
||||
const actual = fs.readFileSync(path.join(workspace, fileName), "utf8");
|
||||
assert(actual === contents, `${fileName} was changed during update/doctor`);
|
||||
}
|
||||
}
|
||||
if (scenario === "stale-source-plugin-shadow") {
|
||||
const staleRoot = path.join(stateDir, "extensions", "opik-openclaw");
|
||||
assert(
|
||||
fs.existsSync(path.join(staleRoot, "src", "index.ts")),
|
||||
"source-only plugin shadow fixture missing",
|
||||
);
|
||||
}
|
||||
if (scenario === "versioned-runtime-deps") {
|
||||
if (stage === "baseline") {
|
||||
return;
|
||||
}
|
||||
const version = process.env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_VERSION || "2026.4.24";
|
||||
const runtimeRoot = path.join(stateDir, "plugin-runtime-deps");
|
||||
const staleVersionedRoots = fs.existsSync(runtimeRoot)
|
||||
? fs.readdirSync(runtimeRoot).filter((entry) => entry.startsWith(`openclaw-${version}-`))
|
||||
: [];
|
||||
assert(
|
||||
staleVersionedRoots.length === 0,
|
||||
`stale versioned runtime deps survived update/doctor: ${staleVersionedRoots.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSessionMetadataMigrated(stateDir) {
|
||||
const legacyStorePath = path.join(stateDir, "sessions", "sessions.json");
|
||||
const agentSessionsDir = path.join(stateDir, "agents", "main", "sessions");
|
||||
const targetStorePath = path.join(agentSessionsDir, "sessions.json");
|
||||
assert(
|
||||
!fs.existsSync(legacyStorePath),
|
||||
`legacy sessions.json survived migration: ${legacyStorePath}`,
|
||||
);
|
||||
for (const sessionId of [
|
||||
LEGACY_SESSION_MAIN_ID,
|
||||
LEGACY_SESSION_DIRECT_ID,
|
||||
LEGACY_SESSION_GROUP_ID,
|
||||
]) {
|
||||
assert(
|
||||
fs.existsSync(path.join(agentSessionsDir, `${sessionId}.jsonl`)),
|
||||
`legacy session transcript was not moved for ${sessionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const store = readMigratedSessionStore(stateDir, targetStorePath);
|
||||
const main = store["agent:main:main"];
|
||||
const direct = store["agent:main:+15551234567"];
|
||||
const group = store["agent:main:slack:channel:cupgrade"];
|
||||
assert(main?.sessionId === LEGACY_SESSION_MAIN_ID, "main legacy session row missing");
|
||||
assert(direct?.sessionId === LEGACY_SESSION_DIRECT_ID, "direct legacy session row missing");
|
||||
assert(group?.sessionId === LEGACY_SESSION_GROUP_ID, "channel legacy session row missing");
|
||||
assert(
|
||||
main?.sessionFile === path.join(agentSessionsDir, `${LEGACY_SESSION_MAIN_ID}.jsonl`),
|
||||
"main legacy session row still points at the old sessions directory",
|
||||
);
|
||||
assert(
|
||||
direct?.sessionFile === path.join(agentSessionsDir, `${LEGACY_SESSION_DIRECT_ID}.jsonl`),
|
||||
"direct legacy session row still points at the old sessions directory",
|
||||
);
|
||||
assert(
|
||||
group?.sessionFile === path.join(agentSessionsDir, `${LEGACY_SESSION_GROUP_ID}.jsonl`),
|
||||
"channel legacy session row still points at the old sessions directory",
|
||||
);
|
||||
assert(
|
||||
main.skillsSnapshot?.prompt === "legacy prompt survives as metadata",
|
||||
"legacy session metadata prompt was not preserved",
|
||||
);
|
||||
assert(
|
||||
main.skillsSnapshot?.resolvedSkills === undefined,
|
||||
"heavy resolvedSkills cache was persisted into migrated session metadata",
|
||||
);
|
||||
}
|
||||
|
||||
function readMigratedSessionStore(stateDir, targetStorePath) {
|
||||
if (fs.existsSync(targetStorePath)) {
|
||||
return readJson(targetStorePath);
|
||||
}
|
||||
|
||||
const dbPath = path.join(stateDir, "agents", "main", "agent", "openclaw-agent.sqlite");
|
||||
assert(fs.existsSync(dbPath), `agent session store missing: ${targetStorePath} or ${dbPath}`);
|
||||
|
||||
let db;
|
||||
try {
|
||||
db = new DatabaseSync(dbPath, { readOnly: true });
|
||||
const rows = db
|
||||
.prepare("SELECT key, value_json FROM cache_entries WHERE scope = ?")
|
||||
.all("session_entries");
|
||||
const store = {};
|
||||
for (const row of rows) {
|
||||
if (typeof row?.key !== "string" || typeof row?.value_json !== "string") {
|
||||
continue;
|
||||
}
|
||||
store[row.key] = JSON.parse(row.value_json);
|
||||
}
|
||||
return store;
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
|
||||
function readInstalledPluginIndex() {
|
||||
const stateDir = requireEnv("OPENCLAW_STATE_DIR");
|
||||
const index = readPluginInstallIndex({ stateDir });
|
||||
assert(index.installRecords, "installed plugin index missing");
|
||||
return index;
|
||||
}
|
||||
|
||||
function assertExternalPluginInstall(records, pluginId, packageName) {
|
||||
const record = records[pluginId];
|
||||
assert(record, `configured external ${pluginId} plugin install record missing`);
|
||||
const installedFromNpm = record.source === "npm";
|
||||
const installedFromOfficialClawHubNpmPack =
|
||||
record.source === "clawhub" &&
|
||||
record.clawhubChannel === "official" &&
|
||||
record.artifactKind === "npm-pack";
|
||||
assert(
|
||||
installedFromNpm || installedFromOfficialClawHubNpmPack,
|
||||
`configured external ${pluginId} plugin must be installed from npm or official ClawHub npm-pack, got: ${record.source}`,
|
||||
);
|
||||
const installPath = resolveHomePath(record.installPath);
|
||||
assert(
|
||||
installPath,
|
||||
`configured external ${pluginId} plugin installPath missing: ${JSON.stringify(record)}`,
|
||||
);
|
||||
assert(
|
||||
fs.existsSync(installPath),
|
||||
`configured external ${pluginId} plugin installPath missing on disk: ${installPath}`,
|
||||
);
|
||||
assert(
|
||||
fs.existsSync(path.join(installPath, "package.json")),
|
||||
`configured external ${pluginId} plugin package.json missing: ${installPath}`,
|
||||
);
|
||||
const packageJson = readJson(path.join(installPath, "package.json"));
|
||||
assert(
|
||||
packageJson.name === packageName,
|
||||
`configured external ${pluginId} package name changed: ${packageJson.name}`,
|
||||
);
|
||||
if (installedFromNpm) {
|
||||
const stateDir = requireEnv("OPENCLAW_STATE_DIR");
|
||||
assert(
|
||||
isPathInsideManagedNpmProjectPackageRoot({ stateDir, installPath, packageName }),
|
||||
`configured external ${pluginId} npm install path outside managed npm project root: ${installPath}`,
|
||||
);
|
||||
assert(
|
||||
String(record.spec ?? record.resolvedSpec ?? "").startsWith(packageName),
|
||||
`configured external ${pluginId} plugin npm spec changed`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
assert(
|
||||
record.clawhubPackage === packageName,
|
||||
`configured external ${pluginId} ClawHub package changed: ${record.clawhubPackage}`,
|
||||
);
|
||||
const extensionsRoot = path.join(requireEnv("OPENCLAW_STATE_DIR"), "extensions");
|
||||
assert(
|
||||
isPathInside(extensionsRoot, installPath),
|
||||
`configured external ${pluginId} ClawHub install path outside managed extensions root: ${installPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertConfiguredPluginInstalls() {
|
||||
const coverage = getCoverage();
|
||||
const stage = process.env.OPENCLAW_UPGRADE_SURVIVOR_ASSERT_STAGE || "survival";
|
||||
if (!hasCoverage(coverage) || !acceptsIntent(coverage, "configured-plugin-installs")) {
|
||||
return;
|
||||
}
|
||||
if (stage === "baseline") {
|
||||
return;
|
||||
}
|
||||
const index = readInstalledPluginIndex();
|
||||
const records = index.installRecords ?? {};
|
||||
assertOptionalConfiguredPluginIndex(records, index.plugins ?? [], {
|
||||
bundled: true,
|
||||
packageName: "@openclaw/matrix",
|
||||
pluginId: "matrix",
|
||||
});
|
||||
assertOptionalConfiguredPluginIndex(records, index.plugins ?? [], {
|
||||
packageName: "@openclaw/brave-plugin",
|
||||
pluginId: "brave",
|
||||
});
|
||||
assert(!records.telegram, "internal telegram plugin should not be installed externally");
|
||||
}
|
||||
|
||||
function assertOptionalConfiguredPluginIndex(
|
||||
records,
|
||||
plugins,
|
||||
{ bundled = false, packageName, pluginId },
|
||||
) {
|
||||
const record = records[pluginId];
|
||||
const plugin = plugins.find((entry) => entry?.pluginId === pluginId);
|
||||
if (record) {
|
||||
assertExternalPluginInstall(records, pluginId, packageName);
|
||||
}
|
||||
if (plugin) {
|
||||
assert(
|
||||
plugin.enabled !== false,
|
||||
`configured ${bundled ? "bundled" : "external"} ${pluginId} plugin is disabled`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertStatusJson([file]) {
|
||||
const status = readJson(file);
|
||||
assert(status && typeof status === "object", "gateway status JSON was not an object");
|
||||
const text = JSON.stringify(status);
|
||||
assert(/running|connected|ok|ready/u.test(text), "gateway status did not report a healthy state");
|
||||
}
|
||||
|
||||
if (command === "seed") {
|
||||
seedState();
|
||||
} else if (command === "assert-config") {
|
||||
assertConfigSurvived();
|
||||
} else if (command === "assert-state") {
|
||||
assertStateSurvived();
|
||||
assertConfiguredPluginInstalls();
|
||||
} else if (command === "assert-status-json") {
|
||||
assertStatusJson(process.argv.slice(3));
|
||||
} else {
|
||||
throw new Error(`unknown upgrade-survivor assertion command: ${command ?? "<missing>"}`);
|
||||
}
|
||||
339
scripts/e2e/lib/upgrade-survivor/config-recipe.mjs
Normal file
339
scripts/e2e/lib/upgrade-survivor/config-recipe.mjs
Normal file
@@ -0,0 +1,339 @@
|
||||
#!/usr/bin/env node
|
||||
// Builds config recipes for upgrade-survivor E2E scenarios.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { parseReleaseVersion } from "../../../lib/npm-publish-plan.mjs";
|
||||
import { buildCmdExeCommandLine, resolveWindowsCmdExePath } from "../../../windows-cmd-helpers.mjs";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const command = args.shift();
|
||||
export const CONFIG_COMMAND_TIMEOUT_MS = 120_000;
|
||||
export const CONFIG_COMMAND_MAX_BUFFER_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
function option(name, fallback) {
|
||||
const index = args.indexOf(name);
|
||||
if (index === -1) {
|
||||
return fallback;
|
||||
}
|
||||
const value = args[index + 1];
|
||||
if (!value) {
|
||||
throw new Error(`missing value for ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function tail(value, max = 2400) {
|
||||
const text = String(value || "");
|
||||
return text.length <= max ? text : text.slice(-max);
|
||||
}
|
||||
|
||||
function writeJson(file, value) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
const configSectionDir = new URL("./config-recipe/", import.meta.url);
|
||||
|
||||
function readConfigSection(fileName) {
|
||||
const fileUrl = new URL(fileName, configSectionDir);
|
||||
return JSON.stringify(JSON.parse(fs.readFileSync(fileUrl, "utf8")));
|
||||
}
|
||||
|
||||
export function isReleaseBefore(version, minimum) {
|
||||
const parsed = parseReleaseVersion(String(version ?? ""));
|
||||
const minimumParsed = parseReleaseFloor(minimum);
|
||||
if (!parsed || !minimumParsed) {
|
||||
return false;
|
||||
}
|
||||
for (const key of ["year", "month", "patch"]) {
|
||||
const delta = parsed[key] - minimumParsed[key];
|
||||
if (delta !== 0) {
|
||||
return delta < 0;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function parseReleaseFloor(version) {
|
||||
const match = /^([0-9]{4})\.([1-9][0-9]?)\.([0-9]+)$/u.exec(String(version ?? ""));
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const [year, month, patch] = match.slice(1).map((part) => Number(part));
|
||||
if (
|
||||
!Number.isSafeInteger(year) ||
|
||||
!Number.isSafeInteger(month) ||
|
||||
!Number.isSafeInteger(patch) ||
|
||||
month < 1 ||
|
||||
month > 12
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { year, month, patch };
|
||||
}
|
||||
|
||||
function configSetJsonFile(id, intent, configPath, fileName) {
|
||||
return {
|
||||
id,
|
||||
intent,
|
||||
argv: ["config", "set", configPath, readConfigSection(fileName), "--strict-json"],
|
||||
};
|
||||
}
|
||||
|
||||
const representativeConfigSteps = [
|
||||
configSetJsonFile("models-openai", "models", "models.providers.openai", "models-openai.json"),
|
||||
configSetJsonFile("agents", "agents", "agents", "agents.json"),
|
||||
configSetJsonFile("skills", "skills", "skills", "skills.json"),
|
||||
configSetJsonFile("plugins", "plugins", "plugins", "plugins.json"),
|
||||
configSetJsonFile(
|
||||
"channels-discord",
|
||||
"discord-channel",
|
||||
"channels.discord",
|
||||
"channels-discord.json",
|
||||
),
|
||||
configSetJsonFile(
|
||||
"channels-telegram",
|
||||
"telegram-channel",
|
||||
"channels.telegram",
|
||||
"channels-telegram.json",
|
||||
),
|
||||
configSetJsonFile(
|
||||
"channels-whatsapp",
|
||||
"whatsapp-channel",
|
||||
"channels.whatsapp",
|
||||
"channels-whatsapp.json",
|
||||
),
|
||||
];
|
||||
|
||||
const scenarioConfigSteps = new Map([
|
||||
[
|
||||
"acpx-openclaw-tools-bridge",
|
||||
[
|
||||
configSetJsonFile(
|
||||
"plugins-acpx-openclaw-tools-bridge",
|
||||
"acpx-openclaw-tools-bridge",
|
||||
"plugins",
|
||||
"plugins-acpx-openclaw-tools-bridge.json",
|
||||
),
|
||||
],
|
||||
],
|
||||
[
|
||||
"feishu-channel",
|
||||
[
|
||||
configSetJsonFile("plugins-feishu", "plugins", "plugins", "plugins-feishu.json"),
|
||||
configSetJsonFile(
|
||||
"channels-feishu",
|
||||
"feishu-channel",
|
||||
"channels.feishu",
|
||||
"channels-feishu.json",
|
||||
),
|
||||
],
|
||||
],
|
||||
[
|
||||
"tilde-log-path",
|
||||
[
|
||||
{
|
||||
id: "logging-file",
|
||||
intent: "logging",
|
||||
argv: ["config", "set", "logging.file", "~/openclaw-upgrade-survivor/gateway.jsonl"],
|
||||
},
|
||||
],
|
||||
],
|
||||
[
|
||||
"configured-plugin-installs",
|
||||
[
|
||||
configSetJsonFile(
|
||||
"plugins-configured-installs",
|
||||
"configured-plugin-installs",
|
||||
"plugins",
|
||||
"plugins-configured-installs.json",
|
||||
),
|
||||
{
|
||||
id: "channels-whatsapp-unset",
|
||||
intent: "configured-plugin-installs",
|
||||
argv: ["config", "unset", "channels.whatsapp"],
|
||||
},
|
||||
configSetJsonFile(
|
||||
"channels-matrix",
|
||||
"configured-plugin-installs",
|
||||
"channels.matrix",
|
||||
"channels-matrix.json",
|
||||
),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
const recipe = [
|
||||
{
|
||||
id: "update-channel",
|
||||
intent: "update",
|
||||
argv: ["config", "set", "update.channel", "stable"],
|
||||
},
|
||||
configSetJsonFile("gateway", "gateway", "gateway", "gateway.json"),
|
||||
...representativeConfigSteps,
|
||||
{
|
||||
id: "validate",
|
||||
intent: "validate",
|
||||
argv: ["config", "validate"],
|
||||
},
|
||||
];
|
||||
|
||||
function selectedScenario() {
|
||||
return process.env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIO || "base";
|
||||
}
|
||||
|
||||
function adaptStepForBaseline(step, baselineVersion, summary) {
|
||||
if (
|
||||
step.intent === "acpx-openclaw-tools-bridge" &&
|
||||
isReleaseBefore(baselineVersion, "2026.4.22")
|
||||
) {
|
||||
if (!summary.skippedIntents.includes("acpx-openclaw-tools-bridge")) {
|
||||
summary.skippedIntents.push("acpx-openclaw-tools-bridge");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!isReleaseBefore(baselineVersion, "2026.4.0")) {
|
||||
return step;
|
||||
}
|
||||
if (step.id === "plugins-feishu" || step.id === "channels-feishu") {
|
||||
if (!summary.skippedIntents.includes("feishu-channel")) {
|
||||
summary.skippedIntents.push("feishu-channel");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (step.id === "agents") {
|
||||
const agents = JSON.parse(step.argv[3]);
|
||||
delete agents.defaults?.skills;
|
||||
for (const agent of agents.list ?? []) {
|
||||
delete agent.thinkingDefault;
|
||||
delete agent.fastModeDefault;
|
||||
delete agent.skills;
|
||||
}
|
||||
summary.skippedIntents.push("agent-modern-preferences");
|
||||
return {
|
||||
...step,
|
||||
argv: [...step.argv.slice(0, 3), JSON.stringify(agents), ...step.argv.slice(4)],
|
||||
};
|
||||
}
|
||||
if (step.intent === "plugins") {
|
||||
const plugins = JSON.parse(step.argv[3]);
|
||||
plugins.allow = (plugins.allow ?? []).filter((id) => id !== "memory");
|
||||
delete plugins.entries?.memory;
|
||||
if (!summary.skippedIntents.includes("memory-plugin-allow")) {
|
||||
summary.skippedIntents.push("memory-plugin-allow");
|
||||
}
|
||||
return {
|
||||
...step,
|
||||
argv: [...step.argv.slice(0, 3), JSON.stringify(plugins), ...step.argv.slice(4)],
|
||||
};
|
||||
}
|
||||
return step;
|
||||
}
|
||||
|
||||
export function resolveUpgradeSurvivorOpenClawCommand(argv, params = {}) {
|
||||
const platform = params.platform ?? process.platform;
|
||||
if (platform === "win32") {
|
||||
const comSpec = params.comSpec ?? resolveWindowsCmdExePath(params.env ?? process.env);
|
||||
return {
|
||||
command: comSpec,
|
||||
args: ["/d", "/s", "/c", buildCmdExeCommandLine("openclaw.cmd", argv)],
|
||||
commandLabel: ["openclaw", ...argv].join(" "),
|
||||
shell: false,
|
||||
windowsVerbatimArguments: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
command: "openclaw",
|
||||
args: argv,
|
||||
commandLabel: ["openclaw", ...argv].join(" "),
|
||||
shell: false,
|
||||
};
|
||||
}
|
||||
|
||||
function errorCode(error) {
|
||||
return error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
|
||||
}
|
||||
|
||||
export function runUpgradeSurvivorOpenClawStep(step, params = {}) {
|
||||
const invocation = resolveUpgradeSurvivorOpenClawCommand(step.argv);
|
||||
const run = params.spawnSyncCommand ?? spawnSync;
|
||||
const timeoutMs = params.timeoutMs ?? CONFIG_COMMAND_TIMEOUT_MS;
|
||||
const maxBuffer = params.maxBufferBytes ?? CONFIG_COMMAND_MAX_BUFFER_BYTES;
|
||||
const result = run(invocation.command, invocation.args, {
|
||||
encoding: "utf8",
|
||||
env: process.env,
|
||||
killSignal: "SIGTERM",
|
||||
maxBuffer,
|
||||
shell: invocation.shell,
|
||||
timeout: timeoutMs,
|
||||
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
||||
});
|
||||
const code = errorCode(result.error);
|
||||
return {
|
||||
id: step.id,
|
||||
intent: step.intent,
|
||||
command: invocation.commandLabel,
|
||||
status: result.status,
|
||||
signal: result.signal,
|
||||
ok: result.status === 0 && !result.error,
|
||||
errorCode: code,
|
||||
errorMessage: result.error?.message ? tail(result.error.message) : undefined,
|
||||
stdout: tail(result.stdout),
|
||||
stderr: tail(result.stderr),
|
||||
};
|
||||
}
|
||||
|
||||
function applyRecipe() {
|
||||
const summaryPath = option("--summary");
|
||||
const baselineVersion = option("--baseline-version", null);
|
||||
const scenario = selectedScenario();
|
||||
const scenarioSteps = scenarioConfigSteps.get(scenario) ?? [];
|
||||
const summary = {
|
||||
source: "baseline-cli-command-recipe",
|
||||
recipe: "upgrade-survivor-v1",
|
||||
baselineVersion,
|
||||
scenario,
|
||||
acceptedIntents: [
|
||||
"update",
|
||||
"gateway",
|
||||
"models",
|
||||
"agents",
|
||||
"skills",
|
||||
"plugins",
|
||||
"discord-channel",
|
||||
"telegram-channel",
|
||||
"whatsapp-channel",
|
||||
...scenarioSteps.map((step) => step.intent),
|
||||
],
|
||||
skippedIntents: [],
|
||||
steps: [],
|
||||
};
|
||||
|
||||
for (const step of [...recipe.slice(0, -1), ...scenarioSteps, recipe.at(-1)]) {
|
||||
const adaptedStep = adaptStepForBaseline(step, baselineVersion, summary);
|
||||
if (!adaptedStep) {
|
||||
continue;
|
||||
}
|
||||
const outcome = runUpgradeSurvivorOpenClawStep(adaptedStep);
|
||||
summary.steps.push(outcome);
|
||||
writeJson(summaryPath, summary);
|
||||
if (!outcome.ok) {
|
||||
const detail = outcome.errorCode ?? outcome.signal ?? outcome.status ?? "unknown";
|
||||
throw new Error(`baseline config recipe failed at ${step.id}: ${detail}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (command === "apply") {
|
||||
applyRecipe();
|
||||
} else {
|
||||
throw new Error(`unknown upgrade-survivor config-recipe command: ${command ?? "<missing>"}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main();
|
||||
}
|
||||
30
scripts/e2e/lib/upgrade-survivor/config-recipe/agents.json
Normal file
30
scripts/e2e/lib/upgrade-survivor/config-recipe/agents.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"defaults": {
|
||||
"model": {
|
||||
"primary": "openai/gpt-5.5"
|
||||
},
|
||||
"contextTokens": 64000
|
||||
},
|
||||
"list": [
|
||||
{
|
||||
"id": "main",
|
||||
"default": true,
|
||||
"name": "Main",
|
||||
"workspace": "~/workspace",
|
||||
"model": {
|
||||
"primary": "openai/gpt-5.5"
|
||||
},
|
||||
"thinkingDefault": "low",
|
||||
"skills": ["memory"]
|
||||
},
|
||||
{
|
||||
"id": "ops",
|
||||
"name": "Ops",
|
||||
"workspace": "~/workspace/ops",
|
||||
"model": {
|
||||
"primary": "openai/gpt-5.5"
|
||||
},
|
||||
"fastModeDefault": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"token": {
|
||||
"source": "env",
|
||||
"provider": "default",
|
||||
"id": "DISCORD_BOT_TOKEN"
|
||||
},
|
||||
"dm": {
|
||||
"policy": "allowlist",
|
||||
"allowFrom": ["111111111111111111"]
|
||||
},
|
||||
"groupPolicy": "allowlist",
|
||||
"guilds": {
|
||||
"222222222222222222": {
|
||||
"slug": "survivor-guild",
|
||||
"channels": {
|
||||
"333333333333333333": {
|
||||
"enabled": true,
|
||||
"requireMention": true,
|
||||
"tools": {
|
||||
"allow": ["message_send"],
|
||||
"deny": ["exec"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"threadBindings": {
|
||||
"enabled": true,
|
||||
"idleHours": 72
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"domain": "feishu",
|
||||
"connectionMode": "webhook",
|
||||
"defaultAccount": "default",
|
||||
"verificationToken": "upgrade-survivor-feishu-verification",
|
||||
"encryptKey": "upgrade-survivor-feishu-encrypt",
|
||||
"webhookPath": "/feishu/events",
|
||||
"webhookHost": "127.0.0.1",
|
||||
"webhookPort": 3000,
|
||||
"accounts": {
|
||||
"default": {
|
||||
"enabled": true,
|
||||
"name": "Upgrade Survivor Feishu",
|
||||
"appId": "cli_upgrade_survivor",
|
||||
"appSecret": {
|
||||
"source": "env",
|
||||
"provider": "default",
|
||||
"id": "FEISHU_APP_SECRET"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dmPolicy": "allowlist",
|
||||
"allowFrom": ["ou_upgrade_survivor"],
|
||||
"groupPolicy": "allowlist",
|
||||
"groupAllowFrom": ["oc_upgrade_survivor"],
|
||||
"groups": {
|
||||
"oc_upgrade_survivor": {
|
||||
"enabled": true,
|
||||
"requireMention": true,
|
||||
"tools": {
|
||||
"allow": ["message_send"],
|
||||
"deny": ["exec"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"homeserver": "https://matrix.example.invalid",
|
||||
"userId": "@upgrade-survivor:matrix.example.invalid",
|
||||
"accessToken": {
|
||||
"source": "env",
|
||||
"provider": "default",
|
||||
"id": "MATRIX_ACCESS_TOKEN"
|
||||
},
|
||||
"dm": {
|
||||
"policy": "allowlist",
|
||||
"allowFrom": ["@driver:matrix.example.invalid"]
|
||||
},
|
||||
"groups": {
|
||||
"!upgrade-survivor:matrix.example.invalid": {
|
||||
"enabled": true,
|
||||
"requireMention": true,
|
||||
"tools": {
|
||||
"allow": ["message_send"],
|
||||
"deny": ["exec"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"botToken": {
|
||||
"source": "env",
|
||||
"provider": "default",
|
||||
"id": "TELEGRAM_BOT_TOKEN"
|
||||
},
|
||||
"dmPolicy": "allowlist",
|
||||
"allowFrom": ["123456789"],
|
||||
"defaultTo": "123456789",
|
||||
"groupPolicy": "allowlist",
|
||||
"groupAllowFrom": ["123456789"],
|
||||
"groups": {
|
||||
"-1001234567890": {
|
||||
"requireMention": true,
|
||||
"tools": {
|
||||
"allow": ["message_send"],
|
||||
"deny": ["exec"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"dmPolicy": "allowlist",
|
||||
"allowFrom": ["+15555550123"],
|
||||
"defaultTo": "+15555550123",
|
||||
"groupPolicy": "allowlist",
|
||||
"groupAllowFrom": ["+15555550123"],
|
||||
"groups": {
|
||||
"120363000000000000@g.us": {
|
||||
"requireMention": true,
|
||||
"tools": {
|
||||
"allow": ["message_send"],
|
||||
"deny": ["exec"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"accounts": {
|
||||
"default": {
|
||||
"enabled": true,
|
||||
"name": "Default WhatsApp"
|
||||
}
|
||||
}
|
||||
}
|
||||
13
scripts/e2e/lib/upgrade-survivor/config-recipe/gateway.json
Normal file
13
scripts/e2e/lib/upgrade-survivor/config-recipe/gateway.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"mode": "local",
|
||||
"port": 18789,
|
||||
"bind": "loopback",
|
||||
"auth": {
|
||||
"mode": "token",
|
||||
"token": {
|
||||
"source": "env",
|
||||
"provider": "default",
|
||||
"id": "GATEWAY_AUTH_TOKEN_REF"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"api": "openai-responses",
|
||||
"apiKey": {
|
||||
"source": "env",
|
||||
"provider": "default",
|
||||
"id": "OPENAI_API_KEY"
|
||||
},
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"models": []
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"allow": ["acpx", "discord", "memory", "telegram", "whatsapp"],
|
||||
"entries": {
|
||||
"acpx": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"openClawToolsMcpBridge": true
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
"enabled": true
|
||||
},
|
||||
"telegram": {
|
||||
"enabled": true
|
||||
},
|
||||
"whatsapp": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"allow": ["brave", "discord", "telegram", "matrix"],
|
||||
"entries": {
|
||||
"brave": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"webSearch": {
|
||||
"apiKey": {
|
||||
"source": "env",
|
||||
"provider": "default",
|
||||
"id": "BRAVE_API_KEY"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
"enabled": true
|
||||
},
|
||||
"matrix": {
|
||||
"enabled": true
|
||||
},
|
||||
"telegram": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"allow": ["discord", "feishu", "memory", "telegram", "whatsapp"],
|
||||
"entries": {
|
||||
"discord": {
|
||||
"enabled": true
|
||||
},
|
||||
"feishu": {
|
||||
"enabled": true
|
||||
},
|
||||
"telegram": {
|
||||
"enabled": true
|
||||
},
|
||||
"whatsapp": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
15
scripts/e2e/lib/upgrade-survivor/config-recipe/plugins.json
Normal file
15
scripts/e2e/lib/upgrade-survivor/config-recipe/plugins.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"allow": ["discord", "memory", "telegram", "whatsapp"],
|
||||
"entries": {
|
||||
"discord": {
|
||||
"enabled": true
|
||||
},
|
||||
"telegram": {
|
||||
"enabled": true
|
||||
},
|
||||
"whatsapp": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"allowBundled": ["memory", "openclaw-testing"],
|
||||
"limits": {
|
||||
"maxSkillsInPrompt": 8,
|
||||
"maxSkillsPromptChars": 30000
|
||||
}
|
||||
}
|
||||
190
scripts/e2e/lib/upgrade-survivor/probe-gateway.mjs
Normal file
190
scripts/e2e/lib/upgrade-survivor/probe-gateway.mjs
Normal file
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Probes gateway state for upgrade-survivor E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { readBoundedResponseText } from "../../../lib/bounded-response.mjs";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
function option(name, fallback) {
|
||||
const index = args.indexOf(name);
|
||||
if (index === -1) {
|
||||
return fallback;
|
||||
}
|
||||
const value = args[index + 1];
|
||||
if (!value) {
|
||||
throw new Error(`missing value for ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionValue(name, envName, fallback) {
|
||||
const index = args.indexOf(name);
|
||||
if (index !== -1) {
|
||||
return {
|
||||
label: name,
|
||||
value: option(name),
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: envName,
|
||||
value: process.env[envName] ?? fallback,
|
||||
};
|
||||
}
|
||||
|
||||
function writeJson(file, value) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function readStrictInteger({ allowZero = false, label, value }) {
|
||||
const text = String(value ?? "").trim();
|
||||
if (!/^\d+$/u.test(text)) {
|
||||
throw new Error(`invalid ${label}: ${text}`);
|
||||
}
|
||||
const parsed = Number(text);
|
||||
if (!Number.isSafeInteger(parsed) || (allowZero ? parsed < 0 : parsed <= 0)) {
|
||||
throw new Error(`invalid ${label}: ${text}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const baseUrl = option("--base-url");
|
||||
const probePath = option("--path");
|
||||
const expectKind = option("--expect");
|
||||
const out = option("--out");
|
||||
const allowFailing = new Set(
|
||||
option("--allow-failing", "")
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
const allowDegradedReady =
|
||||
args.includes("--allow-degraded-ready") ||
|
||||
process.env.OPENCLAW_UPGRADE_SURVIVOR_READYZ_ALLOW_DEGRADED === "1";
|
||||
const timeoutOption = optionValue(
|
||||
"--timeout-ms",
|
||||
"OPENCLAW_UPGRADE_SURVIVOR_PROBE_TIMEOUT_MS",
|
||||
"60000",
|
||||
);
|
||||
const attemptTimeoutOption = optionValue(
|
||||
"--attempt-timeout-ms",
|
||||
"OPENCLAW_UPGRADE_SURVIVOR_PROBE_ATTEMPT_TIMEOUT_MS",
|
||||
"5000",
|
||||
);
|
||||
const maxBodyOption = optionValue(
|
||||
"--max-body-bytes",
|
||||
"OPENCLAW_UPGRADE_SURVIVOR_PROBE_MAX_BODY_BYTES",
|
||||
"1048576",
|
||||
);
|
||||
const timeoutMs = readStrictInteger({ ...timeoutOption, allowZero: true });
|
||||
const attemptTimeoutMs = readStrictInteger(attemptTimeoutOption);
|
||||
const maxBodyBytes = readStrictInteger(maxBodyOption);
|
||||
const url = new URL(probePath, baseUrl).toString();
|
||||
if (expectKind !== "live" && expectKind !== "ready") {
|
||||
throw new Error(`unknown probe expectation: ${expectKind}`);
|
||||
}
|
||||
|
||||
function matchesExpectation(body) {
|
||||
if (expectKind === "live") {
|
||||
return body?.ok === true && body?.status === "live";
|
||||
}
|
||||
return body?.ready === true;
|
||||
}
|
||||
|
||||
function matchesDegradedReadyExpectation(body) {
|
||||
if (expectKind !== "ready" || body?.ready !== false) {
|
||||
return false;
|
||||
}
|
||||
const failing = Array.isArray(body?.failing) ? body.failing : [];
|
||||
return (
|
||||
failing.length > 0 &&
|
||||
allowFailing.size > 0 &&
|
||||
failing.every((entry) => allowFailing.has(String(entry)))
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchProbeText() {
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
const remainingMs = timeoutMs - elapsedMs;
|
||||
const controller = new AbortController();
|
||||
const attemptDeadlineMs = Math.min(attemptTimeoutMs, remainingMs);
|
||||
let timer;
|
||||
const timeoutPromise = new Promise((_resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new Error(`${url} probe attempt timed out after ${attemptDeadlineMs}ms`));
|
||||
controller.abort();
|
||||
}, attemptDeadlineMs);
|
||||
});
|
||||
try {
|
||||
const response = await Promise.race([
|
||||
fetch(url, { method: "GET", signal: controller.signal }),
|
||||
timeoutPromise,
|
||||
]);
|
||||
return {
|
||||
response,
|
||||
text: await readBoundedResponseText(response, `${url} probe`, maxBodyBytes, {
|
||||
formatTooLargeMessage: (_label, bytes) => `${url} probe body exceeded ${bytes} bytes`,
|
||||
timeoutPromise,
|
||||
}),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
let lastError;
|
||||
let lastResult;
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
try {
|
||||
const { response, text } = await fetchProbeText();
|
||||
let body;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch (error) {
|
||||
throw new Error(`${url} returned non-JSON probe body: ${String(error)}`, { cause: error });
|
||||
}
|
||||
lastResult = {
|
||||
body,
|
||||
status: response.status,
|
||||
text,
|
||||
};
|
||||
const healthyExpectationMet = response.ok && matchesExpectation(body);
|
||||
const degradedExpectationMet =
|
||||
allowDegradedReady && response.status === 503 && matchesDegradedReadyExpectation(body);
|
||||
if (healthyExpectationMet || degradedExpectationMet) {
|
||||
writeJson(out, {
|
||||
body,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
path: probePath,
|
||||
status: response.status,
|
||||
url,
|
||||
});
|
||||
process.exit(0);
|
||||
}
|
||||
lastError = response.ok
|
||||
? `${url} did not report ${expectKind} status: ${text}`
|
||||
: `${url} probe failed with HTTP ${response.status}: ${text}`;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
const remainingDelayMs = timeoutMs - (Date.now() - startedAt);
|
||||
if (remainingDelayMs <= 0) {
|
||||
break;
|
||||
}
|
||||
const delayMs = Math.min(500, remainingDelayMs);
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, delayMs);
|
||||
});
|
||||
if (delayMs === remainingDelayMs) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const suffix = lastResult ? ` (last HTTP ${lastResult.status}: ${lastResult.text})` : "";
|
||||
throw new Error(
|
||||
`${url} probe did not satisfy ${expectKind} within ${timeoutMs}ms: ${lastError ?? "no response"}${suffix}`,
|
||||
);
|
||||
1248
scripts/e2e/lib/upgrade-survivor/run.sh
Normal file
1248
scripts/e2e/lib/upgrade-survivor/run.sh
Normal file
File diff suppressed because it is too large
Load Diff
265
scripts/e2e/lib/upgrade-survivor/update-restart-auth.sh
Normal file
265
scripts/e2e/lib/upgrade-survivor/update-restart-auth.sh
Normal file
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
install_update_restart_systemctl_shim() {
|
||||
local shim_dir="$npm_config_prefix/bin"
|
||||
mkdir -p "$shim_dir"
|
||||
cat >"$shim_dir/systemctl" <<'SHIM'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
log_file="${OPENCLAW_UPGRADE_SURVIVOR_SYSTEMCTL_SHIM_LOG:-/tmp/openclaw-systemctl-shim.log}"
|
||||
pid_file="${OPENCLAW_UPGRADE_SURVIVOR_SYSTEMCTL_SHIM_PID_FILE:-/tmp/openclaw-systemctl-shim.pid}"
|
||||
daemon_log="${OPENCLAW_UPGRADE_SURVIVOR_SYSTEMCTL_SHIM_DAEMON_LOG:-/tmp/openclaw-systemctl-shim-gateway.log}"
|
||||
printf '%s\n' "$*" >>"$log_file"
|
||||
|
||||
filtered=()
|
||||
for ((i = 1; i <= $#; i++)); do
|
||||
arg="${!i}"
|
||||
case "$arg" in
|
||||
--user | --quiet | --no-page | --now)
|
||||
;;
|
||||
--property)
|
||||
i=$((i + 1))
|
||||
;;
|
||||
*)
|
||||
filtered+=("$arg")
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
command="${filtered[0]:-status}"
|
||||
|
||||
is_running() {
|
||||
[ -s "$pid_file" ] || return 1
|
||||
local pid
|
||||
pid="$(cat "$pid_file" 2>/dev/null || true)"
|
||||
[ -n "$pid" ] || return 1
|
||||
kill -0 "$pid" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
stop_gateway() {
|
||||
[ -s "$pid_file" ] || return 0
|
||||
local pid
|
||||
pid="$(cat "$pid_file" 2>/dev/null || true)"
|
||||
if [[ "$pid" =~ ^[0-9]+$ ]] && [ "$pid" -gt 1 ] && kill -0 "$pid" >/dev/null 2>&1; then
|
||||
kill "$pid" >/dev/null 2>&1 || true
|
||||
for _ in $(seq 1 100); do
|
||||
kill -0 "$pid" >/dev/null 2>&1 || break
|
||||
sleep 0.1
|
||||
done
|
||||
kill -9 "$pid" >/dev/null 2>&1 || true
|
||||
fi
|
||||
rm -f "$pid_file"
|
||||
}
|
||||
|
||||
unit_path() {
|
||||
printf '%s/.config/systemd/user/openclaw-gateway.service\n' "${HOME:?missing HOME}"
|
||||
}
|
||||
|
||||
load_unit_environment() {
|
||||
local unit="$1"
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
EnvironmentFile=*)
|
||||
local spec="${line#EnvironmentFile=}"
|
||||
for token in $spec; do
|
||||
local file="${token#-}"
|
||||
[ -f "$file" ] || continue
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "$file"
|
||||
set +a
|
||||
done
|
||||
;;
|
||||
Environment=*)
|
||||
local assignment="${line#Environment=}"
|
||||
assignment="${assignment#\"}"
|
||||
assignment="${assignment%\"}"
|
||||
export "$assignment"
|
||||
;;
|
||||
esac
|
||||
done <"$unit"
|
||||
}
|
||||
|
||||
start_gateway() {
|
||||
local unit
|
||||
local exec_start
|
||||
unit="$(unit_path)"
|
||||
exec_start="$(sed -n 's/^ExecStart=//p' "$unit" | tail -n 1)"
|
||||
[ -n "$exec_start" ] || {
|
||||
echo "systemctl shim could not find ExecStart in $unit" >&2
|
||||
return 1
|
||||
}
|
||||
(
|
||||
load_unit_environment "$unit"
|
||||
nohup bash -lc "exec $exec_start" >>"$daemon_log" 2>&1 &
|
||||
printf '%s\n' "$!" >"$pid_file"
|
||||
)
|
||||
}
|
||||
|
||||
case "$command" in
|
||||
daemon-reload | enable | disable)
|
||||
exit 0
|
||||
;;
|
||||
status)
|
||||
is_running && exit 0
|
||||
exit 0
|
||||
;;
|
||||
stop)
|
||||
stop_gateway
|
||||
exit 0
|
||||
;;
|
||||
restart | start)
|
||||
stop_gateway
|
||||
start_gateway
|
||||
exit 0
|
||||
;;
|
||||
is-enabled)
|
||||
exit 0
|
||||
;;
|
||||
is-active)
|
||||
is_running && exit 0
|
||||
exit 3
|
||||
;;
|
||||
show)
|
||||
if is_running; then
|
||||
printf 'ActiveState=active\nSubState=running\nMainPID=%s\nExecMainStatus=0\nExecMainCode=0\n' "$(cat "$pid_file")"
|
||||
else
|
||||
printf 'ActiveState=inactive\nSubState=dead\nMainPID=0\nExecMainStatus=0\nExecMainCode=0\n'
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "systemctl shim unsupported command: $*" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
SHIM
|
||||
chmod +x "$shim_dir/systemctl"
|
||||
export PATH="$shim_dir:$PATH"
|
||||
}
|
||||
|
||||
seed_update_restart_probe_device_auth() {
|
||||
node --input-type=module <<'NODE'
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const stateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
if (!stateDir) {
|
||||
throw new Error("missing OPENCLAW_STATE_DIR");
|
||||
}
|
||||
|
||||
const base64UrlEncode = (buf) =>
|
||||
buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
|
||||
const ed25519SpkiPrefix = Buffer.from("302a300506032b6570032100", "hex");
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
|
||||
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
|
||||
const spki = crypto.createPublicKey(publicKeyPem).export({ type: "spki", format: "der" });
|
||||
const rawPublicKey =
|
||||
spki.length === ed25519SpkiPrefix.length + 32 &&
|
||||
spki.subarray(0, ed25519SpkiPrefix.length).equals(ed25519SpkiPrefix)
|
||||
? spki.subarray(ed25519SpkiPrefix.length)
|
||||
: spki;
|
||||
const publicKeyRaw = base64UrlEncode(rawPublicKey);
|
||||
const deviceId = crypto.createHash("sha256").update(rawPublicKey).digest("hex");
|
||||
const token = base64UrlEncode(crypto.randomBytes(32));
|
||||
const now = Date.now();
|
||||
const scopes = ["operator.read"];
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
||||
try {
|
||||
fs.chmodSync(filePath, 0o600);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
writeJson(path.join(stateDir, "identity", "device.json"), {
|
||||
version: 1,
|
||||
deviceId,
|
||||
publicKeyPem,
|
||||
privateKeyPem,
|
||||
createdAtMs: now,
|
||||
});
|
||||
writeJson(path.join(stateDir, "identity", "device-auth.json"), {
|
||||
version: 1,
|
||||
deviceId,
|
||||
tokens: {
|
||||
operator: {
|
||||
token,
|
||||
role: "operator",
|
||||
scopes,
|
||||
updatedAtMs: now,
|
||||
},
|
||||
},
|
||||
});
|
||||
writeJson(path.join(stateDir, "devices", "paired.json"), {
|
||||
[deviceId]: {
|
||||
deviceId,
|
||||
publicKey: publicKeyRaw,
|
||||
displayName: "upgrade survivor restart probe",
|
||||
platform: process.platform,
|
||||
clientId: "openclaw-cli",
|
||||
clientMode: "probe",
|
||||
role: "operator",
|
||||
roles: ["operator"],
|
||||
scopes,
|
||||
approvedScopes: scopes,
|
||||
tokens: {
|
||||
operator: {
|
||||
token,
|
||||
role: "operator",
|
||||
scopes,
|
||||
createdAtMs: now,
|
||||
},
|
||||
},
|
||||
createdAtMs: now,
|
||||
approvedAtMs: now,
|
||||
},
|
||||
});
|
||||
writeJson(path.join(stateDir, "devices", "pending.json"), {});
|
||||
NODE
|
||||
}
|
||||
|
||||
write_update_restart_service_auth_env() {
|
||||
mkdir -p "$OPENCLAW_STATE_DIR"
|
||||
local dotenv_path="$OPENCLAW_STATE_DIR/.env"
|
||||
local tmp_path="$dotenv_path.tmp.$$"
|
||||
if [ -f "$dotenv_path" ]; then
|
||||
grep -v '^GATEWAY_AUTH_TOKEN_REF=' "$dotenv_path" >"$tmp_path" || true
|
||||
else
|
||||
: >"$tmp_path"
|
||||
fi
|
||||
printf 'GATEWAY_AUTH_TOKEN_REF=%s\n' "$GATEWAY_AUTH_TOKEN_REF" >>"$tmp_path"
|
||||
mv "$tmp_path" "$dotenv_path"
|
||||
printf 'GATEWAY_AUTH_TOKEN_REF=%s\n' "$GATEWAY_AUTH_TOKEN_REF" >"$OPENCLAW_STATE_DIR/gateway.systemd.env"
|
||||
}
|
||||
|
||||
prepare_update_restart_probe_current_install() {
|
||||
local port="$1"
|
||||
local log_file="$2"
|
||||
local command_timeout="${OPENCLAW_UPGRADE_SURVIVOR_COMMAND_TIMEOUT:-900s}"
|
||||
local start_epoch
|
||||
local ready_epoch
|
||||
|
||||
echo "Preparing candidate-auth gateway for automatic update restart."
|
||||
install_update_restart_systemctl_shim
|
||||
seed_update_restart_probe_device_auth
|
||||
start_epoch="$(node -e "process.stdout.write(String(Date.now()))")"
|
||||
env -u OPENCLAW_GATEWAY_TOKEN -u OPENCLAW_GATEWAY_PASSWORD openclaw gateway --port "$port" --bind loopback --allow-unconfigured >"$log_file" 2>&1 &
|
||||
gateway_pid="$!"
|
||||
printf '%s\n' "$gateway_pid" >"$OPENCLAW_UPGRADE_SURVIVOR_SYSTEMCTL_SHIM_PID_FILE"
|
||||
openclaw_e2e_wait_gateway_ready "$gateway_pid" "$log_file" 360 "$port"
|
||||
ready_epoch="$(node -e "process.stdout.write(String(Date.now()))")"
|
||||
start_seconds=$(((ready_epoch - start_epoch + 999) / 1000))
|
||||
write_update_restart_service_auth_env
|
||||
if ! openclaw_e2e_maybe_timeout "$command_timeout" env -u OPENCLAW_GATEWAY_TOKEN -u OPENCLAW_GATEWAY_PASSWORD openclaw gateway install --force --json >"$OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SERVICE_INSTALL_JSON" 2>"$OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SERVICE_INSTALL_ERR"; then
|
||||
echo "gateway service install failed" >&2
|
||||
cat "$OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SERVICE_INSTALL_ERR" >&2 || true
|
||||
cat "$OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SERVICE_INSTALL_JSON" >&2 || true
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
Reference in New Issue
Block a user