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

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:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View 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>"}`);
}

View 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();
}

View 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
}
]
}

View File

@@ -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
}
}

View File

@@ -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"]
}
}
}
}

View File

@@ -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"]
}
}
}
}

View File

@@ -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"]
}
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -0,0 +1,13 @@
{
"mode": "local",
"port": 18789,
"bind": "loopback",
"auth": {
"mode": "token",
"token": {
"source": "env",
"provider": "default",
"id": "GATEWAY_AUTH_TOKEN_REF"
}
}
}

View File

@@ -0,0 +1,10 @@
{
"api": "openai-responses",
"apiKey": {
"source": "env",
"provider": "default",
"id": "OPENAI_API_KEY"
},
"baseUrl": "https://api.openai.com/v1",
"models": []
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -0,0 +1,15 @@
{
"enabled": true,
"allow": ["discord", "memory", "telegram", "whatsapp"],
"entries": {
"discord": {
"enabled": true
},
"telegram": {
"enabled": true
},
"whatsapp": {
"enabled": true
}
}
}

View File

@@ -0,0 +1,7 @@
{
"allowBundled": ["memory", "openclaw-testing"],
"limits": {
"maxSkillsInPrompt": 8,
"maxSkillsPromptChars": 30000
}
}

View 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}`,
);

File diff suppressed because it is too large Load Diff

View 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
}