@gguf/claw
Version:
Multi-channel AI gateway with extensible messaging integrations
1,511 lines • 50.8 kB
JavaScript
import { Gt as resolveLegacyStateDirs, Kt as resolveNewStateDir, Pt as CONFIG_PATH, Q as isRecord, Yt as resolveStateDir, it as resolveHomeDir, qt as resolveOAuthDir } from "./entry.js";
import { t as formatCliCommand } from "./command-format-D3syQOZg.js";
import { i as buildAgentMainSessionKey, l as normalizeAgentId, r as DEFAULT_MAIN_KEY, t as DEFAULT_ACCOUNT_ID } from "./session-key-BGiG_JcT.js";
import { l as resolveDefaultAgentId } from "./agent-scope-RzK9Zcks.js";
import { d as OpenClawSchema, n as migrateLegacyConfig, o as readConfigFileSnapshot } from "./config-B2kL1ciP.js";
import { c as saveSessionStore, j as canonicalizeMainSessionAlias } from "./sessions-BD5dyLxb.js";
import { a as listTelegramAccountIds, o as resolveTelegramAccount } from "./normalize-Db7Xtx2v.js";
import { t as note } from "./note-D3Xn5qjj.js";
import { t as applyPluginAutoEnable } from "./plugin-auto-enable-DR5UZ2Ky.js";
import { t as isWithinDir } from "./path-safety-B5mLREM1.js";
import { n as normalizeTelegramAllowFromEntry, t as isNumericTelegramUserId } from "./allow-from-BZCKLkjL.js";
import os from "node:os";
import path from "node:path";
import fs from "node:fs";
import JSON5 from "json5";
import fs$1 from "node:fs/promises";
//#region src/channels/telegram/api.ts
async function fetchTelegramChatId(params) {
const url = `https://api.telegram.org/bot${params.token}/getChat?chat_id=${encodeURIComponent(params.chatId)}`;
try {
const res = await fetch(url, params.signal ? { signal: params.signal } : void 0);
if (!res.ok) return null;
const data = await res.json().catch(() => null);
const id = data?.ok ? data?.result?.id : void 0;
if (typeof id === "number" || typeof id === "string") return String(id);
return null;
} catch {
return null;
}
}
//#endregion
//#region src/commands/doctor-legacy-config.ts
function normalizeLegacyConfigValues(cfg) {
const changes = [];
let next = cfg;
const isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
const normalizeDmAliases = (params) => {
let changed = false;
let updated = params.entry;
const rawDm = updated.dm;
const dm = isRecord(rawDm) ? structuredClone(rawDm) : null;
let dmChanged = false;
const allowFromEqual = (a, b) => {
if (!Array.isArray(a) || !Array.isArray(b)) return false;
const na = a.map((v) => String(v).trim()).filter(Boolean);
const nb = b.map((v) => String(v).trim()).filter(Boolean);
if (na.length !== nb.length) return false;
return na.every((v, i) => v === nb[i]);
};
const topDmPolicy = updated.dmPolicy;
const legacyDmPolicy = dm?.policy;
if (topDmPolicy === void 0 && legacyDmPolicy !== void 0) {
updated = {
...updated,
dmPolicy: legacyDmPolicy
};
changed = true;
if (dm) {
delete dm.policy;
dmChanged = true;
}
changes.push(`Moved ${params.pathPrefix}.dm.policy → ${params.pathPrefix}.dmPolicy.`);
} else if (topDmPolicy !== void 0 && legacyDmPolicy !== void 0) {
if (topDmPolicy === legacyDmPolicy) {
if (dm) {
delete dm.policy;
dmChanged = true;
changes.push(`Removed ${params.pathPrefix}.dm.policy (dmPolicy already set).`);
}
}
}
const topAllowFrom = updated.allowFrom;
const legacyAllowFrom = dm?.allowFrom;
if (topAllowFrom === void 0 && legacyAllowFrom !== void 0) {
updated = {
...updated,
allowFrom: legacyAllowFrom
};
changed = true;
if (dm) {
delete dm.allowFrom;
dmChanged = true;
}
changes.push(`Moved ${params.pathPrefix}.dm.allowFrom → ${params.pathPrefix}.allowFrom.`);
} else if (topAllowFrom !== void 0 && legacyAllowFrom !== void 0) {
if (allowFromEqual(topAllowFrom, legacyAllowFrom)) {
if (dm) {
delete dm.allowFrom;
dmChanged = true;
changes.push(`Removed ${params.pathPrefix}.dm.allowFrom (allowFrom already set).`);
}
}
}
if (dm && isRecord(rawDm) && dmChanged) if (Object.keys(dm).length === 0) {
if (updated.dm !== void 0) {
const { dm: _ignored, ...rest } = updated;
updated = rest;
changed = true;
changes.push(`Removed empty ${params.pathPrefix}.dm after migration.`);
}
} else {
updated = {
...updated,
dm
};
changed = true;
}
return {
entry: updated,
changed
};
};
const normalizeProvider = (provider) => {
const rawEntry = next.channels?.[provider];
if (!isRecord(rawEntry)) return;
const base = normalizeDmAliases({
provider,
entry: rawEntry,
pathPrefix: `channels.${provider}`
});
let updated = base.entry;
let changed = base.changed;
const rawAccounts = updated.accounts;
if (isRecord(rawAccounts)) {
let accountsChanged = false;
const accounts = { ...rawAccounts };
for (const [accountId, rawAccount] of Object.entries(rawAccounts)) {
if (!isRecord(rawAccount)) continue;
const res = normalizeDmAliases({
provider,
entry: rawAccount,
pathPrefix: `channels.${provider}.accounts.${accountId}`
});
if (res.changed) {
accounts[accountId] = res.entry;
accountsChanged = true;
}
}
if (accountsChanged) {
updated = {
...updated,
accounts
};
changed = true;
}
}
if (changed) next = {
...next,
channels: {
...next.channels,
[provider]: updated
}
};
};
normalizeProvider("slack");
normalizeProvider("discord");
const legacyAckReaction = cfg.messages?.ackReaction?.trim();
const hasWhatsAppConfig = cfg.channels?.whatsapp !== void 0;
if (legacyAckReaction && hasWhatsAppConfig) {
if (!(cfg.channels?.whatsapp?.ackReaction !== void 0)) {
const legacyScope = cfg.messages?.ackReactionScope ?? "group-mentions";
let direct = true;
let group = "mentions";
if (legacyScope === "all") {
direct = true;
group = "always";
} else if (legacyScope === "direct") {
direct = true;
group = "never";
} else if (legacyScope === "group-all") {
direct = false;
group = "always";
} else if (legacyScope === "group-mentions") {
direct = false;
group = "mentions";
}
next = {
...next,
channels: {
...next.channels,
whatsapp: {
...next.channels?.whatsapp,
ackReaction: {
emoji: legacyAckReaction,
direct,
group
}
}
}
};
changes.push(`Copied messages.ackReaction → channels.whatsapp.ackReaction (scope: ${legacyScope}).`);
}
}
return {
config: next,
changes
};
}
//#endregion
//#region src/infra/state-migrations.fs.ts
function safeReadDir(dir) {
try {
return fs.readdirSync(dir, { withFileTypes: true });
} catch {
return [];
}
}
function existsDir(dir) {
try {
return fs.existsSync(dir) && fs.statSync(dir).isDirectory();
} catch {
return false;
}
}
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
function fileExists(p) {
try {
return fs.existsSync(p) && fs.statSync(p).isFile();
} catch {
return false;
}
}
function isLegacyWhatsAppAuthFile(name) {
if (name === "creds.json" || name === "creds.json.bak") return true;
if (!name.endsWith(".json")) return false;
return /^(app-state-sync|session|sender-key|pre-key)-/.test(name);
}
function readSessionStoreJson5(storePath) {
try {
const raw = fs.readFileSync(storePath, "utf-8");
const parsed = JSON5.parse(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return {
store: parsed,
ok: true
};
} catch {}
return {
store: {},
ok: false
};
}
//#endregion
//#region src/infra/state-migrations.ts
let autoMigrateStateDirChecked = false;
function isSurfaceGroupKey(key) {
return key.includes(":group:") || key.includes(":channel:");
}
function isLegacyGroupKey(key) {
const trimmed = key.trim();
if (!trimmed) return false;
if (trimmed.startsWith("group:")) return true;
const lower = trimmed.toLowerCase();
if (!lower.includes("@g.us")) return false;
if (!trimmed.includes(":")) return true;
if (lower.startsWith("whatsapp:") && !trimmed.includes(":group:")) return true;
return false;
}
function canonicalizeSessionKeyForAgent(params) {
const agentId = normalizeAgentId(params.agentId);
const raw = params.key.trim();
if (!raw) return raw;
if (raw.toLowerCase() === "global" || raw.toLowerCase() === "unknown") return raw.toLowerCase();
const canonicalMain = canonicalizeMainSessionAlias({
cfg: { session: {
scope: params.scope,
mainKey: params.mainKey
} },
agentId,
sessionKey: raw
});
if (canonicalMain !== raw) return canonicalMain.toLowerCase();
if (raw.toLowerCase().startsWith("agent:")) return raw.toLowerCase();
if (raw.toLowerCase().startsWith("subagent:")) return `agent:${agentId}:subagent:${raw.slice(9)}`.toLowerCase();
if (raw.startsWith("group:")) {
const id = raw.slice(6).trim();
if (!id) return raw;
return `agent:${agentId}:${id.toLowerCase().includes("@g.us") ? "whatsapp" : "unknown"}:group:${id}`.toLowerCase();
}
if (!raw.includes(":") && raw.toLowerCase().includes("@g.us")) return `agent:${agentId}:whatsapp:group:${raw}`.toLowerCase();
if (raw.toLowerCase().startsWith("whatsapp:") && raw.toLowerCase().includes("@g.us")) {
const cleaned = raw.slice(9).trim().replace(/^group:/i, "").trim();
if (cleaned && !isSurfaceGroupKey(raw)) return `agent:${agentId}:whatsapp:group:${cleaned}`.toLowerCase();
}
if (isSurfaceGroupKey(raw)) return `agent:${agentId}:${raw}`.toLowerCase();
return `agent:${agentId}:${raw}`.toLowerCase();
}
function pickLatestLegacyDirectEntry(store) {
let best = null;
let bestUpdated = -1;
for (const [key, entry] of Object.entries(store)) {
if (!entry || typeof entry !== "object") continue;
const normalized = key.trim();
if (!normalized) continue;
if (normalized === "global") continue;
if (normalized.startsWith("agent:")) continue;
if (normalized.toLowerCase().startsWith("subagent:")) continue;
if (isLegacyGroupKey(normalized) || isSurfaceGroupKey(normalized)) continue;
const updatedAt = typeof entry.updatedAt === "number" ? entry.updatedAt : 0;
if (updatedAt > bestUpdated) {
bestUpdated = updatedAt;
best = entry;
}
}
return best;
}
function normalizeSessionEntry(entry) {
const sessionId = typeof entry.sessionId === "string" ? entry.sessionId : null;
if (!sessionId) return null;
const updatedAt = typeof entry.updatedAt === "number" && Number.isFinite(entry.updatedAt) ? entry.updatedAt : Date.now();
const normalized = {
...entry,
sessionId,
updatedAt
};
const rec = normalized;
if (typeof rec.groupChannel !== "string" && typeof rec.room === "string") rec.groupChannel = rec.room;
delete rec.room;
return normalized;
}
function resolveUpdatedAt(entry) {
return typeof entry.updatedAt === "number" && Number.isFinite(entry.updatedAt) ? entry.updatedAt : 0;
}
function mergeSessionEntry(params) {
if (!params.existing) return params.incoming;
const existingUpdated = resolveUpdatedAt(params.existing);
const incomingUpdated = resolveUpdatedAt(params.incoming);
if (incomingUpdated > existingUpdated) return params.incoming;
if (incomingUpdated < existingUpdated) return params.existing;
return params.preferIncomingOnTie ? params.incoming : params.existing;
}
function canonicalizeSessionStore(params) {
const canonical = {};
const meta = /* @__PURE__ */ new Map();
const legacyKeys = [];
for (const [key, entry] of Object.entries(params.store)) {
if (!entry || typeof entry !== "object") continue;
const canonicalKey = canonicalizeSessionKeyForAgent({
key,
agentId: params.agentId,
mainKey: params.mainKey,
scope: params.scope
});
const isCanonical = canonicalKey === key;
if (!isCanonical) legacyKeys.push(key);
const existing = canonical[canonicalKey];
if (!existing) {
canonical[canonicalKey] = entry;
meta.set(canonicalKey, {
isCanonical,
updatedAt: resolveUpdatedAt(entry)
});
continue;
}
const existingMeta = meta.get(canonicalKey);
const incomingUpdated = resolveUpdatedAt(entry);
const existingUpdated = existingMeta?.updatedAt ?? resolveUpdatedAt(existing);
if (incomingUpdated > existingUpdated) {
canonical[canonicalKey] = entry;
meta.set(canonicalKey, {
isCanonical,
updatedAt: incomingUpdated
});
continue;
}
if (incomingUpdated < existingUpdated) continue;
if (existingMeta?.isCanonical && !isCanonical) continue;
if (!existingMeta?.isCanonical && isCanonical) {
canonical[canonicalKey] = entry;
meta.set(canonicalKey, {
isCanonical,
updatedAt: incomingUpdated
});
continue;
}
}
return {
store: canonical,
legacyKeys
};
}
function listLegacySessionKeys(params) {
const legacy = [];
for (const key of Object.keys(params.store)) if (canonicalizeSessionKeyForAgent({
key,
agentId: params.agentId,
mainKey: params.mainKey,
scope: params.scope
}) !== key) legacy.push(key);
return legacy;
}
function emptyDirOrMissing(dir) {
if (!existsDir(dir)) return true;
return safeReadDir(dir).length === 0;
}
function removeDirIfEmpty(dir) {
if (!existsDir(dir)) return;
if (!emptyDirOrMissing(dir)) return;
try {
fs.rmdirSync(dir);
} catch {}
}
function resolveSymlinkTarget(linkPath) {
try {
const target = fs.readlinkSync(linkPath);
return path.resolve(path.dirname(linkPath), target);
} catch {
return null;
}
}
function formatStateDirMigration(legacyDir, targetDir) {
return `State dir: ${legacyDir} → ${targetDir} (legacy path now symlinked)`;
}
function isDirPath(filePath) {
try {
return fs.statSync(filePath).isDirectory();
} catch {
return false;
}
}
function isLegacyTreeSymlinkMirror(currentDir, realTargetDir) {
let entries;
try {
entries = fs.readdirSync(currentDir, { withFileTypes: true });
} catch {
return false;
}
if (entries.length === 0) return false;
for (const entry of entries) {
const entryPath = path.join(currentDir, entry.name);
let stat;
try {
stat = fs.lstatSync(entryPath);
} catch {
return false;
}
if (stat.isSymbolicLink()) {
const resolvedTarget = resolveSymlinkTarget(entryPath);
if (!resolvedTarget) return false;
let resolvedRealTarget;
try {
resolvedRealTarget = fs.realpathSync(resolvedTarget);
} catch {
return false;
}
if (!isWithinDir(realTargetDir, resolvedRealTarget)) return false;
continue;
}
if (stat.isDirectory()) {
if (!isLegacyTreeSymlinkMirror(entryPath, realTargetDir)) return false;
continue;
}
return false;
}
return true;
}
function isLegacyDirSymlinkMirror(legacyDir, targetDir) {
let realTargetDir;
try {
realTargetDir = fs.realpathSync(targetDir);
} catch {
return false;
}
return isLegacyTreeSymlinkMirror(legacyDir, realTargetDir);
}
async function autoMigrateLegacyStateDir(params) {
if (autoMigrateStateDirChecked) return {
migrated: false,
skipped: true,
changes: [],
warnings: []
};
autoMigrateStateDirChecked = true;
if ((params.env ?? process.env).OPENCLAW_STATE_DIR?.trim()) return {
migrated: false,
skipped: true,
changes: [],
warnings: []
};
const homedir = params.homedir ?? os.homedir;
const targetDir = resolveNewStateDir(homedir);
const legacyDirs = resolveLegacyStateDirs(homedir);
let legacyDir = legacyDirs.find((dir) => {
try {
return fs.existsSync(dir);
} catch {
return false;
}
});
const warnings = [];
const changes = [];
let legacyStat = null;
try {
legacyStat = legacyDir ? fs.lstatSync(legacyDir) : null;
} catch {
legacyStat = null;
}
if (!legacyStat) return {
migrated: false,
skipped: false,
changes,
warnings
};
if (!legacyStat.isDirectory() && !legacyStat.isSymbolicLink()) {
warnings.push(`Legacy state path is not a directory: ${legacyDir}`);
return {
migrated: false,
skipped: false,
changes,
warnings
};
}
let symlinkDepth = 0;
while (legacyStat.isSymbolicLink()) {
const legacyTarget = legacyDir ? resolveSymlinkTarget(legacyDir) : null;
if (!legacyTarget) {
warnings.push(`Legacy state dir is a symlink (${legacyDir ?? "unknown"}); could not resolve target.`);
return {
migrated: false,
skipped: false,
changes,
warnings
};
}
if (path.resolve(legacyTarget) === path.resolve(targetDir)) return {
migrated: false,
skipped: false,
changes,
warnings
};
if (legacyDirs.some((dir) => path.resolve(dir) === path.resolve(legacyTarget))) {
legacyDir = legacyTarget;
try {
legacyStat = fs.lstatSync(legacyDir);
} catch {
legacyStat = null;
}
if (!legacyStat) {
warnings.push(`Legacy state dir missing after symlink resolution: ${legacyDir}`);
return {
migrated: false,
skipped: false,
changes,
warnings
};
}
if (!legacyStat.isDirectory() && !legacyStat.isSymbolicLink()) {
warnings.push(`Legacy state path is not a directory: ${legacyDir}`);
return {
migrated: false,
skipped: false,
changes,
warnings
};
}
symlinkDepth += 1;
if (symlinkDepth > 2) {
warnings.push(`Legacy state dir symlink chain too deep: ${legacyDir}`);
return {
migrated: false,
skipped: false,
changes,
warnings
};
}
continue;
}
warnings.push(`Legacy state dir is a symlink (${legacyDir ?? "unknown"} → ${legacyTarget}); skipping auto-migration.`);
return {
migrated: false,
skipped: false,
changes,
warnings
};
}
if (isDirPath(targetDir)) {
if (legacyDir && isLegacyDirSymlinkMirror(legacyDir, targetDir)) return {
migrated: false,
skipped: false,
changes,
warnings
};
warnings.push(`State dir migration skipped: target already exists (${targetDir}). Remove or merge manually.`);
return {
migrated: false,
skipped: false,
changes,
warnings
};
}
try {
if (!legacyDir) throw new Error("Legacy state dir not found");
fs.renameSync(legacyDir, targetDir);
} catch (err) {
warnings.push(`Failed to move legacy state dir (${legacyDir ?? "unknown"} → ${targetDir}): ${String(err)}`);
return {
migrated: false,
skipped: false,
changes,
warnings
};
}
try {
if (!legacyDir) throw new Error("Legacy state dir not found");
fs.symlinkSync(targetDir, legacyDir, "dir");
changes.push(formatStateDirMigration(legacyDir, targetDir));
} catch (err) {
try {
if (process.platform === "win32") {
if (!legacyDir) throw new Error("Legacy state dir not found", { cause: err });
fs.symlinkSync(targetDir, legacyDir, "junction");
changes.push(formatStateDirMigration(legacyDir, targetDir));
} else throw err;
} catch (fallbackErr) {
try {
if (!legacyDir) throw new Error("Legacy state dir not found", { cause: fallbackErr });
fs.renameSync(targetDir, legacyDir);
warnings.push(`State dir migration rolled back (failed to link legacy path): ${String(fallbackErr)}`);
return {
migrated: false,
skipped: false,
changes: [],
warnings
};
} catch (rollbackErr) {
warnings.push(`State dir moved but failed to link legacy path (${legacyDir ?? "unknown"} → ${targetDir}): ${String(fallbackErr)}`);
warnings.push(`Rollback failed; set OPENCLAW_STATE_DIR=${targetDir} to avoid split state: ${String(rollbackErr)}`);
changes.push(`State dir: ${legacyDir ?? "unknown"} → ${targetDir}`);
}
}
}
return {
migrated: changes.length > 0,
skipped: false,
changes,
warnings
};
}
async function detectLegacyStateMigrations(params) {
const env = params.env ?? process.env;
const stateDir = resolveStateDir(env, params.homedir ?? os.homedir);
const oauthDir = resolveOAuthDir(env, stateDir);
const targetAgentId = normalizeAgentId(resolveDefaultAgentId(params.cfg));
const rawMainKey = params.cfg.session?.mainKey;
const targetMainKey = typeof rawMainKey === "string" && rawMainKey.trim().length > 0 ? rawMainKey.trim() : DEFAULT_MAIN_KEY;
const targetScope = params.cfg.session?.scope;
const sessionsLegacyDir = path.join(stateDir, "sessions");
const sessionsLegacyStorePath = path.join(sessionsLegacyDir, "sessions.json");
const sessionsTargetDir = path.join(stateDir, "agents", targetAgentId, "sessions");
const sessionsTargetStorePath = path.join(sessionsTargetDir, "sessions.json");
const legacySessionEntries = safeReadDir(sessionsLegacyDir);
const hasLegacySessions = fileExists(sessionsLegacyStorePath) || legacySessionEntries.some((e) => e.isFile() && e.name.endsWith(".jsonl"));
const targetSessionParsed = fileExists(sessionsTargetStorePath) ? readSessionStoreJson5(sessionsTargetStorePath) : {
store: {},
ok: true
};
const legacyKeys = targetSessionParsed.ok ? listLegacySessionKeys({
store: targetSessionParsed.store,
agentId: targetAgentId,
mainKey: targetMainKey,
scope: targetScope
}) : [];
const legacyAgentDir = path.join(stateDir, "agent");
const targetAgentDir = path.join(stateDir, "agents", targetAgentId, "agent");
const hasLegacyAgentDir = existsDir(legacyAgentDir);
const targetWhatsAppAuthDir = path.join(oauthDir, "whatsapp", DEFAULT_ACCOUNT_ID);
const hasLegacyWhatsAppAuth = fileExists(path.join(oauthDir, "creds.json")) && !fileExists(path.join(targetWhatsAppAuthDir, "creds.json"));
const legacyTelegramAllowFromPath = path.join(oauthDir, "telegram-allowFrom.json");
const targetTelegramAllowFromPath = path.join(oauthDir, `telegram-${DEFAULT_ACCOUNT_ID}-allowFrom.json`);
const hasLegacyTelegramAllowFrom = fileExists(legacyTelegramAllowFromPath) && !fileExists(targetTelegramAllowFromPath);
const preview = [];
if (hasLegacySessions) preview.push(`- Sessions: ${sessionsLegacyDir} → ${sessionsTargetDir}`);
if (legacyKeys.length > 0) preview.push(`- Sessions: canonicalize legacy keys in ${sessionsTargetStorePath}`);
if (hasLegacyAgentDir) preview.push(`- Agent dir: ${legacyAgentDir} → ${targetAgentDir}`);
if (hasLegacyWhatsAppAuth) preview.push(`- WhatsApp auth: ${oauthDir} → ${targetWhatsAppAuthDir} (keep oauth.json)`);
if (hasLegacyTelegramAllowFrom) preview.push(`- Telegram pairing allowFrom: ${legacyTelegramAllowFromPath} → ${targetTelegramAllowFromPath}`);
return {
targetAgentId,
targetMainKey,
targetScope,
stateDir,
oauthDir,
sessions: {
legacyDir: sessionsLegacyDir,
legacyStorePath: sessionsLegacyStorePath,
targetDir: sessionsTargetDir,
targetStorePath: sessionsTargetStorePath,
hasLegacy: hasLegacySessions || legacyKeys.length > 0,
legacyKeys
},
agentDir: {
legacyDir: legacyAgentDir,
targetDir: targetAgentDir,
hasLegacy: hasLegacyAgentDir
},
whatsappAuth: {
legacyDir: oauthDir,
targetDir: targetWhatsAppAuthDir,
hasLegacy: hasLegacyWhatsAppAuth
},
pairingAllowFrom: {
legacyTelegramPath: legacyTelegramAllowFromPath,
targetTelegramPath: targetTelegramAllowFromPath,
hasLegacyTelegram: hasLegacyTelegramAllowFrom
},
preview
};
}
async function migrateLegacySessions(detected, now) {
const changes = [];
const warnings = [];
if (!detected.sessions.hasLegacy) return {
changes,
warnings
};
ensureDir(detected.sessions.targetDir);
const legacyParsed = fileExists(detected.sessions.legacyStorePath) ? readSessionStoreJson5(detected.sessions.legacyStorePath) : {
store: {},
ok: true
};
const targetParsed = fileExists(detected.sessions.targetStorePath) ? readSessionStoreJson5(detected.sessions.targetStorePath) : {
store: {},
ok: true
};
const legacyStore = legacyParsed.store;
const targetStore = targetParsed.store;
const canonicalizedTarget = canonicalizeSessionStore({
store: targetStore,
agentId: detected.targetAgentId,
mainKey: detected.targetMainKey,
scope: detected.targetScope
});
const canonicalizedLegacy = canonicalizeSessionStore({
store: legacyStore,
agentId: detected.targetAgentId,
mainKey: detected.targetMainKey,
scope: detected.targetScope
});
const merged = { ...canonicalizedTarget.store };
for (const [key, entry] of Object.entries(canonicalizedLegacy.store)) merged[key] = mergeSessionEntry({
existing: merged[key],
incoming: entry,
preferIncomingOnTie: false
});
const mainKey = buildAgentMainSessionKey({
agentId: detected.targetAgentId,
mainKey: detected.targetMainKey
});
if (!merged[mainKey]) {
const latest = pickLatestLegacyDirectEntry(legacyStore);
if (latest?.sessionId) {
merged[mainKey] = latest;
changes.push(`Migrated latest direct-chat session → ${mainKey}`);
}
}
if (!legacyParsed.ok) warnings.push(`Legacy sessions store unreadable; left in place at ${detected.sessions.legacyStorePath}`);
if ((legacyParsed.ok || targetParsed.ok) && (Object.keys(legacyStore).length > 0 || Object.keys(targetStore).length > 0)) {
const normalized = {};
for (const [key, entry] of Object.entries(merged)) {
const normalizedEntry = normalizeSessionEntry(entry);
if (!normalizedEntry) continue;
normalized[key] = normalizedEntry;
}
await saveSessionStore(detected.sessions.targetStorePath, normalized, { skipMaintenance: true });
changes.push(`Merged sessions store → ${detected.sessions.targetStorePath}`);
if (canonicalizedTarget.legacyKeys.length > 0) changes.push(`Canonicalized ${canonicalizedTarget.legacyKeys.length} legacy session key(s)`);
}
const entries = safeReadDir(detected.sessions.legacyDir);
for (const entry of entries) {
if (!entry.isFile()) continue;
if (entry.name === "sessions.json") continue;
const from = path.join(detected.sessions.legacyDir, entry.name);
const to = path.join(detected.sessions.targetDir, entry.name);
if (fileExists(to)) continue;
try {
fs.renameSync(from, to);
changes.push(`Moved ${entry.name} → agents/${detected.targetAgentId}/sessions`);
} catch (err) {
warnings.push(`Failed moving ${from}: ${String(err)}`);
}
}
if (legacyParsed.ok) try {
if (fileExists(detected.sessions.legacyStorePath)) fs.rmSync(detected.sessions.legacyStorePath, { force: true });
} catch {}
removeDirIfEmpty(detected.sessions.legacyDir);
if (safeReadDir(detected.sessions.legacyDir).filter((e) => e.isFile()).length > 0) {
const backupDir = `${detected.sessions.legacyDir}.legacy-${now()}`;
try {
fs.renameSync(detected.sessions.legacyDir, backupDir);
warnings.push(`Left legacy sessions at ${backupDir}`);
} catch {}
}
return {
changes,
warnings
};
}
async function migrateLegacyAgentDir(detected, now) {
const changes = [];
const warnings = [];
if (!detected.agentDir.hasLegacy) return {
changes,
warnings
};
ensureDir(detected.agentDir.targetDir);
const entries = safeReadDir(detected.agentDir.legacyDir);
for (const entry of entries) {
const from = path.join(detected.agentDir.legacyDir, entry.name);
const to = path.join(detected.agentDir.targetDir, entry.name);
if (fs.existsSync(to)) continue;
try {
fs.renameSync(from, to);
changes.push(`Moved agent file ${entry.name} → agents/${detected.targetAgentId}/agent`);
} catch (err) {
warnings.push(`Failed moving ${from}: ${String(err)}`);
}
}
removeDirIfEmpty(detected.agentDir.legacyDir);
if (!emptyDirOrMissing(detected.agentDir.legacyDir)) {
const backupDir = path.join(detected.stateDir, "agents", detected.targetAgentId, `agent.legacy-${now()}`);
try {
fs.renameSync(detected.agentDir.legacyDir, backupDir);
warnings.push(`Left legacy agent dir at ${backupDir}`);
} catch (err) {
warnings.push(`Failed relocating legacy agent dir: ${String(err)}`);
}
}
return {
changes,
warnings
};
}
async function migrateLegacyWhatsAppAuth(detected) {
const changes = [];
const warnings = [];
if (!detected.whatsappAuth.hasLegacy) return {
changes,
warnings
};
ensureDir(detected.whatsappAuth.targetDir);
const entries = safeReadDir(detected.whatsappAuth.legacyDir);
for (const entry of entries) {
if (!entry.isFile()) continue;
if (entry.name === "oauth.json") continue;
if (!isLegacyWhatsAppAuthFile(entry.name)) continue;
const from = path.join(detected.whatsappAuth.legacyDir, entry.name);
const to = path.join(detected.whatsappAuth.targetDir, entry.name);
if (fileExists(to)) continue;
try {
fs.renameSync(from, to);
changes.push(`Moved WhatsApp auth ${entry.name} → whatsapp/default`);
} catch (err) {
warnings.push(`Failed moving ${from}: ${String(err)}`);
}
}
return {
changes,
warnings
};
}
async function migrateLegacyTelegramPairingAllowFrom(detected) {
const changes = [];
const warnings = [];
if (!detected.pairingAllowFrom.hasLegacyTelegram) return {
changes,
warnings
};
const legacyPath = detected.pairingAllowFrom.legacyTelegramPath;
const targetPath = detected.pairingAllowFrom.targetTelegramPath;
try {
ensureDir(path.dirname(targetPath));
fs.copyFileSync(legacyPath, targetPath);
changes.push(`Copied Telegram pairing allowFrom → ${targetPath}`);
} catch (err) {
warnings.push(`Failed migrating Telegram pairing allowFrom (${legacyPath}): ${String(err)}`);
}
return {
changes,
warnings
};
}
async function runLegacyStateMigrations(params) {
const now = params.now ?? (() => Date.now());
const detected = params.detected;
const sessions = await migrateLegacySessions(detected, now);
const agentDir = await migrateLegacyAgentDir(detected, now);
const whatsappAuth = await migrateLegacyWhatsAppAuth(detected);
const telegramPairingAllowFrom = await migrateLegacyTelegramPairingAllowFrom(detected);
return {
changes: [
...sessions.changes,
...agentDir.changes,
...whatsappAuth.changes,
...telegramPairingAllowFrom.changes
],
warnings: [
...sessions.warnings,
...agentDir.warnings,
...whatsappAuth.warnings,
...telegramPairingAllowFrom.warnings
]
};
}
//#endregion
//#region src/commands/doctor-config-flow.ts
function normalizeIssuePath(path) {
return path.filter((part) => typeof part !== "symbol");
}
function isUnrecognizedKeysIssue(issue) {
return issue.code === "unrecognized_keys";
}
function formatPath(parts) {
if (parts.length === 0) return "<root>";
let out = "";
for (const part of parts) {
if (typeof part === "number") {
out += `[${part}]`;
continue;
}
out = out ? `${out}.${part}` : part;
}
return out || "<root>";
}
function resolvePathTarget(root, path) {
let current = root;
for (const part of path) {
if (typeof part === "number") {
if (!Array.isArray(current)) return null;
if (part < 0 || part >= current.length) return null;
current = current[part];
continue;
}
if (!current || typeof current !== "object" || Array.isArray(current)) return null;
const record = current;
if (!(part in record)) return null;
current = record[part];
}
return current;
}
function stripUnknownConfigKeys(config) {
const parsed = OpenClawSchema.safeParse(config);
if (parsed.success) return {
config,
removed: []
};
const next = structuredClone(config);
const removed = [];
for (const issue of parsed.error.issues) {
if (!isUnrecognizedKeysIssue(issue)) continue;
const path = normalizeIssuePath(issue.path);
const target = resolvePathTarget(next, path);
if (!target || typeof target !== "object" || Array.isArray(target)) continue;
const record = target;
for (const key of issue.keys) {
if (typeof key !== "string") continue;
if (!(key in record)) continue;
delete record[key];
removed.push(formatPath([...path, key]));
}
}
return {
config: next,
removed
};
}
function noteOpencodeProviderOverrides(cfg) {
const providers = cfg.models?.providers;
if (!providers) return;
const overrides = [];
if (providers.opencode) overrides.push("opencode");
if (providers["opencode-zen"]) overrides.push("opencode-zen");
if (overrides.length === 0) return;
const lines = overrides.flatMap((id) => {
const providerEntry = providers[id];
const api = isRecord(providerEntry) && typeof providerEntry.api === "string" ? providerEntry.api : void 0;
return [`- models.providers.${id} is set; this overrides the built-in OpenCode Zen catalog.`, api ? `- models.providers.${id}.api=${api}` : null].filter((line) => Boolean(line));
});
lines.push("- Remove these entries to restore per-model API routing + costs (then re-run onboarding if needed).");
note(lines.join("\n"), "OpenCode Zen");
}
function noteIncludeConfinementWarning(snapshot) {
const includeIssue = (snapshot.issues ?? []).find((issue) => issue.message.includes("Include path escapes config directory") || issue.message.includes("Include path resolves outside config directory"));
if (!includeIssue) return;
note([
`- $include paths must stay under: ${path.dirname(snapshot.path ?? CONFIG_PATH)}`,
"- Move shared include files under that directory and update to relative paths like \"./shared/common.json\".",
`- Error: ${includeIssue.message}`
].join("\n"), "Doctor warnings");
}
function asObjectRecord(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value;
}
function collectTelegramAccountScopes(cfg) {
const scopes = [];
const telegram = asObjectRecord(cfg.channels?.telegram);
if (!telegram) return scopes;
scopes.push({
prefix: "channels.telegram",
account: telegram
});
const accounts = asObjectRecord(telegram.accounts);
if (!accounts) return scopes;
for (const key of Object.keys(accounts)) {
const account = asObjectRecord(accounts[key]);
if (!account) continue;
scopes.push({
prefix: `channels.telegram.accounts.${key}`,
account
});
}
return scopes;
}
function collectTelegramAllowFromLists(prefix, account) {
const refs = [{
pathLabel: `${prefix}.allowFrom`,
holder: account,
key: "allowFrom"
}, {
pathLabel: `${prefix}.groupAllowFrom`,
holder: account,
key: "groupAllowFrom"
}];
const groups = asObjectRecord(account.groups);
if (!groups) return refs;
for (const groupId of Object.keys(groups)) {
const group = asObjectRecord(groups[groupId]);
if (!group) continue;
refs.push({
pathLabel: `${prefix}.groups.${groupId}.allowFrom`,
holder: group,
key: "allowFrom"
});
const topics = asObjectRecord(group.topics);
if (!topics) continue;
for (const topicId of Object.keys(topics)) {
const topic = asObjectRecord(topics[topicId]);
if (!topic) continue;
refs.push({
pathLabel: `${prefix}.groups.${groupId}.topics.${topicId}.allowFrom`,
holder: topic,
key: "allowFrom"
});
}
}
return refs;
}
function scanTelegramAllowFromUsernameEntries(cfg) {
const hits = [];
const scanList = (pathLabel, list) => {
if (!Array.isArray(list)) return;
for (const entry of list) {
const normalized = normalizeTelegramAllowFromEntry(entry);
if (!normalized || normalized === "*") continue;
if (isNumericTelegramUserId(normalized)) continue;
hits.push({
path: pathLabel,
entry: String(entry).trim()
});
}
};
for (const scope of collectTelegramAccountScopes(cfg)) for (const ref of collectTelegramAllowFromLists(scope.prefix, scope.account)) scanList(ref.pathLabel, ref.holder[ref.key]);
return hits;
}
async function maybeRepairTelegramAllowFromUsernames(cfg) {
if (scanTelegramAllowFromUsernameEntries(cfg).length === 0) return {
config: cfg,
changes: []
};
const tokens = Array.from(new Set(listTelegramAccountIds(cfg).map((accountId) => resolveTelegramAccount({
cfg,
accountId
})).map((account) => account.tokenSource === "none" ? "" : account.token).map((token) => token.trim()).filter(Boolean)));
if (tokens.length === 0) return {
config: cfg,
changes: [`- Telegram allowFrom contains @username entries, but no Telegram bot token is configured; cannot auto-resolve (run onboarding or replace with numeric sender IDs).`]
};
const resolveUserId = async (raw) => {
const trimmed = raw.trim();
if (!trimmed) return null;
const stripped = normalizeTelegramAllowFromEntry(trimmed);
if (!stripped || stripped === "*") return null;
if (isNumericTelegramUserId(stripped)) return stripped;
if (/\s/.test(stripped)) return null;
const username = stripped.startsWith("@") ? stripped : `@${stripped}`;
for (const token of tokens) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 4e3);
try {
const id = await fetchTelegramChatId({
token,
chatId: username,
signal: controller.signal
});
if (id) return id;
} catch {} finally {
clearTimeout(timeout);
}
}
return null;
};
const changes = [];
const next = structuredClone(cfg);
const repairList = async (pathLabel, holder, key) => {
const raw = holder[key];
if (!Array.isArray(raw)) return;
const out = [];
const replaced = [];
for (const entry of raw) {
const normalized = normalizeTelegramAllowFromEntry(entry);
if (!normalized) continue;
if (normalized === "*") {
out.push("*");
continue;
}
if (isNumericTelegramUserId(normalized)) {
out.push(normalized);
continue;
}
const resolved = await resolveUserId(String(entry));
if (resolved) {
out.push(resolved);
replaced.push({
from: String(entry).trim(),
to: resolved
});
} else out.push(String(entry).trim());
}
const deduped = [];
const seen = /* @__PURE__ */ new Set();
for (const entry of out) {
const k = String(entry).trim();
if (!k || seen.has(k)) continue;
seen.add(k);
deduped.push(entry);
}
holder[key] = deduped;
if (replaced.length > 0) {
for (const rep of replaced.slice(0, 5)) changes.push(`- ${pathLabel}: resolved ${rep.from} -> ${rep.to}`);
if (replaced.length > 5) changes.push(`- ${pathLabel}: resolved ${replaced.length - 5} more @username entries`);
}
};
const repairAccount = async (prefix, account) => {
for (const ref of collectTelegramAllowFromLists(prefix, account)) await repairList(ref.pathLabel, ref.holder, ref.key);
};
for (const scope of collectTelegramAccountScopes(next)) await repairAccount(scope.prefix, scope.account);
if (changes.length === 0) return {
config: cfg,
changes: []
};
return {
config: next,
changes
};
}
function collectDiscordAccountScopes(cfg) {
const scopes = [];
const discord = asObjectRecord(cfg.channels?.discord);
if (!discord) return scopes;
scopes.push({
prefix: "channels.discord",
account: discord
});
const accounts = asObjectRecord(discord.accounts);
if (!accounts) return scopes;
for (const key of Object.keys(accounts)) {
const account = asObjectRecord(accounts[key]);
if (!account) continue;
scopes.push({
prefix: `channels.discord.accounts.${key}`,
account
});
}
return scopes;
}
function collectDiscordIdLists(prefix, account) {
const refs = [{
pathLabel: `${prefix}.allowFrom`,
holder: account,
key: "allowFrom"
}];
const dm = asObjectRecord(account.dm);
if (dm) {
refs.push({
pathLabel: `${prefix}.dm.allowFrom`,
holder: dm,
key: "allowFrom"
});
refs.push({
pathLabel: `${prefix}.dm.groupChannels`,
holder: dm,
key: "groupChannels"
});
}
const execApprovals = asObjectRecord(account.execApprovals);
if (execApprovals) refs.push({
pathLabel: `${prefix}.execApprovals.approvers`,
holder: execApprovals,
key: "approvers"
});
const guilds = asObjectRecord(account.guilds);
if (!guilds) return refs;
for (const guildId of Object.keys(guilds)) {
const guild = asObjectRecord(guilds[guildId]);
if (!guild) continue;
refs.push({
pathLabel: `${prefix}.guilds.${guildId}.users`,
holder: guild,
key: "users"
});
refs.push({
pathLabel: `${prefix}.guilds.${guildId}.roles`,
holder: guild,
key: "roles"
});
const channels = asObjectRecord(guild.channels);
if (!channels) continue;
for (const channelId of Object.keys(channels)) {
const channel = asObjectRecord(channels[channelId]);
if (!channel) continue;
refs.push({
pathLabel: `${prefix}.guilds.${guildId}.channels.${channelId}.users`,
holder: channel,
key: "users"
});
refs.push({
pathLabel: `${prefix}.guilds.${guildId}.channels.${channelId}.roles`,
holder: channel,
key: "roles"
});
}
}
return refs;
}
function scanDiscordNumericIdEntries(cfg) {
const hits = [];
const scanList = (pathLabel, list) => {
if (!Array.isArray(list)) return;
for (const [index, entry] of list.entries()) {
if (typeof entry !== "number") continue;
hits.push({
path: `${pathLabel}[${index}]`,
entry
});
}
};
for (const scope of collectDiscordAccountScopes(cfg)) for (const ref of collectDiscordIdLists(scope.prefix, scope.account)) scanList(ref.pathLabel, ref.holder[ref.key]);
return hits;
}
function maybeRepairDiscordNumericIds(cfg) {
if (scanDiscordNumericIdEntries(cfg).length === 0) return {
config: cfg,
changes: []
};
const next = structuredClone(cfg);
const changes = [];
const repairList = (pathLabel, holder, key) => {
const raw = holder[key];
if (!Array.isArray(raw)) return;
let converted = 0;
const updated = raw.map((entry) => {
if (typeof entry === "number") {
converted += 1;
return String(entry);
}
return entry;
});
if (converted === 0) return;
holder[key] = updated;
changes.push(`- ${pathLabel}: converted ${converted} numeric ${converted === 1 ? "entry" : "entries"} to strings`);
};
for (const scope of collectDiscordAccountScopes(next)) for (const ref of collectDiscordIdLists(scope.prefix, scope.account)) repairList(ref.pathLabel, ref.holder, ref.key);
if (changes.length === 0) return {
config: cfg,
changes: []
};
return {
config: next,
changes
};
}
/**
* Scan all channel configs for dmPolicy="open" without allowFrom including "*".
* This configuration is rejected by the schema validator but can easily occur when
* users (or integrations) set dmPolicy to "open" without realising that an explicit
* allowFrom wildcard is also required.
*/
function maybeRepairOpenPolicyAllowFrom(cfg) {
const channels = cfg.channels;
if (!channels || typeof channels !== "object") return {
config: cfg,
changes: []
};
const next = structuredClone(cfg);
const changes = [];
const resolveAllowFromMode = (channelName) => {
if (channelName === "googlechat") return "nestedOnly";
if (channelName === "discord" || channelName === "slack") return "topOrNested";
return "topOnly";
};
const hasWildcard = (list) => list?.some((v) => String(v).trim() === "*") ?? false;
const ensureWildcard = (account, prefix, mode) => {
const dmEntry = account.dm;
const dm = dmEntry && typeof dmEntry === "object" && !Array.isArray(dmEntry) ? dmEntry : void 0;
if ((account.dmPolicy ?? dm?.policy ?? void 0) !== "open") return;
const topAllowFrom = account.allowFrom;
const nestedAllowFrom = dm?.allowFrom;
if (mode === "nestedOnly") {
if (hasWildcard(nestedAllowFrom)) return;
if (Array.isArray(nestedAllowFrom)) {
nestedAllowFrom.push("*");
changes.push(`- ${prefix}.dm.allowFrom: added "*" (required by dmPolicy="open")`);
return;
}
const nextDm = dm ?? {};
nextDm.allowFrom = ["*"];
account.dm = nextDm;
changes.push(`- ${prefix}.dm.allowFrom: set to ["*"] (required by dmPolicy="open")`);
return;
}
if (mode === "topOrNested") {
if (hasWildcard(topAllowFrom) || hasWildcard(nestedAllowFrom)) return;
if (Array.isArray(topAllowFrom)) {
topAllowFrom.push("*");
changes.push(`- ${prefix}.allowFrom: added "*" (required by dmPolicy="open")`);
} else if (Array.isArray(nestedAllowFrom)) {
nestedAllowFrom.push("*");
changes.push(`- ${prefix}.dm.allowFrom: added "*" (required by dmPolicy="open")`);
} else {
account.allowFrom = ["*"];
changes.push(`- ${prefix}.allowFrom: set to ["*"] (required by dmPolicy="open")`);
}
return;
}
if (hasWildcard(topAllowFrom)) return;
if (Array.isArray(topAllowFrom)) {
topAllowFrom.push("*");
changes.push(`- ${prefix}.allowFrom: added "*" (required by dmPolicy="open")`);
} else {
account.allowFrom = ["*"];
changes.push(`- ${prefix}.allowFrom: set to ["*"] (required by dmPolicy="open")`);
}
};
const nextChannels = next.channels;
for (const [channelName, channelConfig] of Object.entries(nextChannels)) {
if (!channelConfig || typeof channelConfig !== "object") continue;
const allowFromMode = resolveAllowFromMode(channelName);
ensureWildcard(channelConfig, `channels.${channelName}`, allowFromMode);
const accounts = channelConfig.accounts;
if (accounts && typeof accounts === "object") {
for (const [accountName, accountConfig] of Object.entries(accounts)) if (accountConfig && typeof accountConfig === "object") ensureWildcard(accountConfig, `channels.${channelName}.accounts.${accountName}`, allowFromMode);
}
}
if (changes.length === 0) return {
config: cfg,
changes: []
};
return {
config: next,
changes
};
}
async function maybeMigrateLegacyConfig() {
const changes = [];
const home = resolveHomeDir();
if (!home) return changes;
const targetDir = path.join(home, ".openclaw");
const targetPath = path.join(targetDir, "openclaw.json");
try {
await fs$1.access(targetPath);
return changes;
} catch {}
const legacyCandidates = [
path.join(home, ".clawdbot", "clawdbot.json"),
path.join(home, ".moldbot", "moldbot.json"),
path.join(home, ".moltbot", "moltbot.json")
];
let legacyPath = null;
for (const candidate of legacyCandidates) try {
await fs$1.access(candidate);
legacyPath = candidate;
break;
} catch {}
if (!legacyPath) return changes;
await fs$1.mkdir(targetDir, { recursive: true });
try {
await fs$1.copyFile(legacyPath, targetPath, fs$1.constants.COPYFILE_EXCL);
changes.push(`Migrated legacy config: ${legacyPath} -> ${targetPath}`);
} catch {}
return changes;
}
async function loadAndMaybeMigrateDoctorConfig(params) {
const shouldRepair = params.options.repair === true || params.options.yes === true;
const stateDirResult = await autoMigrateLegacyStateDir({ env: process.env });
if (stateDirResult.changes.length > 0) note(stateDirResult.changes.map((entry) => `- ${entry}`).join("\n"), "Doctor changes");
if (stateDirResult.warnings.length > 0) note(stateDirResult.warnings.map((entry) => `- ${entry}`).join("\n"), "Doctor warnings");
const legacyConfigChanges = await maybeMigrateLegacyConfig();
if (legacyConfigChanges.length > 0) note(legacyConfigChanges.map((entry) => `- ${entry}`).join("\n"), "Doctor changes");
let snapshot = await readConfigFileSnapshot();
const baseCfg = snapshot.config ?? {};
let cfg = baseCfg;
let candidate = structuredClone(baseCfg);
let pendingChanges = false;
let shouldWriteConfig = false;
const fixHints = [];
if (snapshot.exists && !snapshot.valid && snapshot.legacyIssues.length === 0) {
note("Config invalid; doctor will run with best-effort config.", "Config");
noteIncludeConfinementWarning(snapshot);
}
const warnings = snapshot.warnings ?? [];
if (warnings.length > 0) note(warnings.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n"), "Config warnings");
if (snapshot.legacyIssues.length > 0) {
note(snapshot.legacyIssues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n"), "Legacy config keys detected");
const { config: migrated, changes } = migrateLegacyConfig(snapshot.parsed);
if (changes.length > 0) note(changes.join("\n"), "Doctor changes");
if (migrated) {
candidate = migrated;
pendingChanges = pendingChanges || changes.length > 0;
}
if (shouldRepair) {
if (migrated) cfg = migrated;
} else fixHints.push(`Run "${formatCliCommand("openclaw doctor --fix")}" to apply legacy migrations.`);
}
const normalized = normalizeLegacyConfigValues(candidate);
if (normalized.changes.length > 0) {
note(normalized.changes.join("\n"), "Doctor changes");
candidate = normalized.config;
pendingChanges = true;
if (shouldRepair) cfg = normalized.config;
else fixHints.push(`Run "${formatCliCommand("openclaw doctor --fix")}" to apply these changes.`);
}
const autoEnable = applyPluginAutoEnable({
config: candidate,
env: process.env
});
if (autoEnable.changes.length > 0) {
note(autoEnable.changes.join("\n"), "Doctor changes");
candidate = autoEnable.config;
pendingChanges = true;
if (shouldRepair) cfg = autoEnable.config;
else fixHints.push(`Run "${formatCliCommand("openclaw doctor --fix")}" to apply these changes.`);
}
if (shouldRepair) {
const repair = await maybeRepairTelegramAllowFromUsernames(candidate);
if (repair.changes.length > 0) {
note(repair.changes.join("\n"), "Doctor changes");
candidate = repair.config;
pendingChanges = true;
cfg = repair.config;
}
const discordRepair = maybeRepairDiscordNumericIds(candidate);
if (discordRepair.changes.length > 0) {
note(discordRepair.changes.join("\n"), "Doctor changes");
candidate = discordRepair.config;
pendingChanges = true;
cfg = discordRepair.config;
}
const allowFromRepair = maybeRepairOpenPolicyAllowFrom(candidate);
if (allowFromRepair.changes.length > 0) {
note(allowFromRepair.changes.join("\n"), "Doctor changes");
candidate = allowFromRepair.config;
pendingChanges = true;
cfg = allowFromRepair.config;
}
} else {
const hits = scanTelegramAllowFromUsernameEntries(candidate);
if (hits.length > 0) note([`- Telegram allowFrom contains ${hits.length} non-numeric entries (e.g. ${hits[0]?.entry ?? "@"}); Telegram authorization requires numeric sender IDs.`, `- Run "${formatCliCommand("openclaw doctor --fix")}" to auto-resolve @username entries to numeric IDs (requires a Telegram bot token).`].join("\n"), "Doctor warnings");
const discordHits = scanDiscordNumericIdEntries(candidate);
if (discordHits.length > 0) note([`- Discord allowlists contain ${discordHits.length} numeric entries (e.g. ${discordHits[0]?.path}=${discordHits[0]?.entry}).`, `- Discord IDs must be strings; run "${formatCliCommand("openclaw doctor --fix")}" to convert numeric IDs to quoted strings.`].join("\n"), "Doctor warnings");
const allowFromScan = maybeRepairOpenPolicyAllowFrom(candidate);
if (allowFromScan.changes.length > 0) note([...allowFromScan.changes, `- Run "${formatCliCommand("openclaw doctor --fix")}" to add missing allowFrom wildcards.`].join("\n"), "Doctor warnings");
}
const unknown = stripUnknownConfigKeys(candidate);
if (unknown.removed.length > 0) {
const lines = unknown.removed.map((path) => `- ${path}`).join("\n");
candidate = unknown.config;
pendingChanges = true;
if (shouldRepair) {
cfg = unknown.config;
note(lines, "Doctor changes");
} else {
note(lines, "Unknown config keys");
fixHints.push("Run \"openclaw doctor --fix\" to remove these keys.");
}
}
if (!shouldRepair && pendingChanges) {
if (await params.confirm({
message: "Apply recommended config repairs now?",
initialValue: true
})) {
cfg = candidate;
shouldWriteConfig = true;
} else if (fixHints.length > 0) note(fixHints.join("\n"), "Doctor");
}
noteOpencodeProviderOverrides(cfg);
return {
cfg,
path: snapshot.path ?? CONFIG_PATH,
shouldWriteConfig,
sourceConfigValid: snapshot.valid
};
}
//#endregion
export { detectLegacyStateMigrations as n, runLegacyStateMigrations as r, loadAndMaybeMigrateDoctorConfig as t };