mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-04 12:20:28 +00:00
826 lines
26 KiB
TypeScript
826 lines
26 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
|
import { formatCliCommand } from "../cli/command-format.js";
|
|
import type { OpenClawConfig } from "../config/config.js";
|
|
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
|
|
import {
|
|
formatSessionArchiveTimestamp,
|
|
isPrimarySessionTranscriptFileName,
|
|
loadSessionStore,
|
|
resolveMainSessionKey,
|
|
resolveSessionFilePath,
|
|
resolveSessionFilePathOptions,
|
|
resolveSessionTranscriptsDirForAgent,
|
|
resolveStorePath,
|
|
} from "../config/sessions.js";
|
|
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
|
|
import { parseAgentSessionKey } from "../sessions/session-key-utils.js";
|
|
import { note } from "../terminal/note.js";
|
|
import { shortenHomePath } from "../utils.js";
|
|
|
|
type DoctorPrompterLike = {
|
|
confirmSkipInNonInteractive: (params: {
|
|
message: string;
|
|
initialValue?: boolean;
|
|
}) => Promise<boolean>;
|
|
};
|
|
|
|
function existsDir(dir: string): boolean {
|
|
try {
|
|
return fs.existsSync(dir) && fs.statSync(dir).isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function existsFile(filePath: string): boolean {
|
|
try {
|
|
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function canWriteDir(dir: string): boolean {
|
|
try {
|
|
fs.accessSync(dir, fs.constants.W_OK);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function ensureDir(dir: string): { ok: boolean; error?: string } {
|
|
try {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
return { ok: true };
|
|
} catch (err) {
|
|
return { ok: false, error: String(err) };
|
|
}
|
|
}
|
|
|
|
function dirPermissionHint(dir: string): string | null {
|
|
const uid = typeof process.getuid === "function" ? process.getuid() : null;
|
|
const gid = typeof process.getgid === "function" ? process.getgid() : null;
|
|
try {
|
|
const stat = fs.statSync(dir);
|
|
if (uid !== null && stat.uid !== uid) {
|
|
return `Owner mismatch (uid ${stat.uid}). Run: sudo chown -R $USER "${dir}"`;
|
|
}
|
|
if (gid !== null && stat.gid !== gid) {
|
|
return `Group mismatch (gid ${stat.gid}). If access fails, run: sudo chown -R $USER "${dir}"`;
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function addUserRwx(mode: number): number {
|
|
const perms = mode & 0o777;
|
|
return perms | 0o700;
|
|
}
|
|
|
|
function countJsonlLines(filePath: string): number {
|
|
try {
|
|
const raw = fs.readFileSync(filePath, "utf-8");
|
|
if (!raw) {
|
|
return 0;
|
|
}
|
|
let count = 0;
|
|
for (let i = 0; i < raw.length; i += 1) {
|
|
if (raw[i] === "\n") {
|
|
count += 1;
|
|
}
|
|
}
|
|
if (!raw.endsWith("\n")) {
|
|
count += 1;
|
|
}
|
|
return count;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
function findOtherStateDirs(stateDir: string): string[] {
|
|
const resolvedState = path.resolve(stateDir);
|
|
const roots =
|
|
process.platform === "darwin" ? ["/Users"] : process.platform === "linux" ? ["/home"] : [];
|
|
const found: string[] = [];
|
|
for (const root of roots) {
|
|
let entries: fs.Dirent[] = [];
|
|
try {
|
|
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) {
|
|
continue;
|
|
}
|
|
if (entry.name.startsWith(".")) {
|
|
continue;
|
|
}
|
|
const candidates = [".openclaw"].map((dir) => path.resolve(root, entry.name, dir));
|
|
for (const candidate of candidates) {
|
|
if (candidate === resolvedState) {
|
|
continue;
|
|
}
|
|
if (existsDir(candidate)) {
|
|
found.push(candidate);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return found;
|
|
}
|
|
|
|
function isPathUnderRoot(targetPath: string, rootPath: string): boolean {
|
|
const normalizedTarget = path.resolve(targetPath);
|
|
const normalizedRoot = path.resolve(rootPath);
|
|
const rootToken = path.parse(normalizedRoot).root;
|
|
if (normalizedRoot === rootToken) {
|
|
return normalizedTarget.startsWith(rootToken);
|
|
}
|
|
return (
|
|
normalizedTarget === normalizedRoot ||
|
|
normalizedTarget.startsWith(`${normalizedRoot}${path.sep}`)
|
|
);
|
|
}
|
|
|
|
function tryResolveRealPath(targetPath: string): string | null {
|
|
try {
|
|
return fs.realpathSync(targetPath);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function decodeMountInfoPath(value: string): string {
|
|
return value.replace(/\\([0-7]{3})/g, (_, octal: string) =>
|
|
String.fromCharCode(Number.parseInt(octal, 8)),
|
|
);
|
|
}
|
|
|
|
function escapeControlCharsForTerminal(value: string): string {
|
|
let escaped = "";
|
|
for (const char of value) {
|
|
if (char === "\u001b") {
|
|
escaped += "\\x1b";
|
|
continue;
|
|
}
|
|
if (char === "\r") {
|
|
escaped += "\\r";
|
|
continue;
|
|
}
|
|
if (char === "\n") {
|
|
escaped += "\\n";
|
|
continue;
|
|
}
|
|
if (char === "\t") {
|
|
escaped += "\\t";
|
|
continue;
|
|
}
|
|
const code = char.charCodeAt(0);
|
|
if ((code >= 0 && code <= 8) || code === 11 || code === 12 || (code >= 14 && code <= 31)) {
|
|
escaped += `\\x${code.toString(16).padStart(2, "0")}`;
|
|
continue;
|
|
}
|
|
if (code === 127) {
|
|
escaped += "\\x7f";
|
|
continue;
|
|
}
|
|
escaped += char;
|
|
}
|
|
return escaped;
|
|
}
|
|
|
|
type LinuxMountInfoEntry = {
|
|
mountPoint: string;
|
|
fsType: string;
|
|
source: string;
|
|
};
|
|
|
|
export type LinuxSdBackedStateDir = {
|
|
path: string;
|
|
mountPoint: string;
|
|
fsType: string;
|
|
source: string;
|
|
};
|
|
|
|
function parseLinuxMountInfo(rawMountInfo: string): LinuxMountInfoEntry[] {
|
|
const entries: LinuxMountInfoEntry[] = [];
|
|
for (const line of rawMountInfo.split("\n")) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) {
|
|
continue;
|
|
}
|
|
const separatorIndex = trimmed.indexOf(" - ");
|
|
if (separatorIndex === -1) {
|
|
continue;
|
|
}
|
|
|
|
const left = trimmed.slice(0, separatorIndex);
|
|
const right = trimmed.slice(separatorIndex + 3);
|
|
const leftFields = left.split(" ");
|
|
const rightFields = right.split(" ");
|
|
if (leftFields.length < 5 || rightFields.length < 2) {
|
|
continue;
|
|
}
|
|
|
|
entries.push({
|
|
mountPoint: decodeMountInfoPath(leftFields[4]),
|
|
fsType: rightFields[0],
|
|
source: decodeMountInfoPath(rightFields[1]),
|
|
});
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
function isPathUnderRootWithPathOps(
|
|
targetPath: string,
|
|
rootPath: string,
|
|
pathOps: Pick<typeof path, "resolve" | "sep" | "parse">,
|
|
): boolean {
|
|
const normalizedTarget = pathOps.resolve(targetPath);
|
|
const normalizedRoot = pathOps.resolve(rootPath);
|
|
const rootToken = pathOps.parse(normalizedRoot).root;
|
|
if (normalizedRoot === rootToken) {
|
|
return normalizedTarget.startsWith(rootToken);
|
|
}
|
|
return (
|
|
normalizedTarget === normalizedRoot ||
|
|
normalizedTarget.startsWith(`${normalizedRoot}${pathOps.sep}`)
|
|
);
|
|
}
|
|
|
|
function findLinuxMountInfoEntryForPath(
|
|
targetPath: string,
|
|
entries: LinuxMountInfoEntry[],
|
|
pathOps: Pick<typeof path, "resolve" | "sep" | "parse">,
|
|
): LinuxMountInfoEntry | null {
|
|
const normalizedTarget = pathOps.resolve(targetPath);
|
|
let bestMatch: LinuxMountInfoEntry | null = null;
|
|
for (const entry of entries) {
|
|
if (!isPathUnderRootWithPathOps(normalizedTarget, entry.mountPoint, pathOps)) {
|
|
continue;
|
|
}
|
|
if (
|
|
!bestMatch ||
|
|
pathOps.resolve(entry.mountPoint).length > pathOps.resolve(bestMatch.mountPoint).length
|
|
) {
|
|
bestMatch = entry;
|
|
}
|
|
}
|
|
return bestMatch;
|
|
}
|
|
|
|
function isMmcDevicePath(devicePath: string, pathOps: Pick<typeof path, "basename">): boolean {
|
|
const name = pathOps.basename(devicePath);
|
|
return /^mmcblk\d+(?:p\d+)?$/.test(name);
|
|
}
|
|
|
|
function tryReadLinuxMountInfo(): string | null {
|
|
try {
|
|
return fs.readFileSync("/proc/self/mountinfo", "utf8");
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function detectLinuxSdBackedStateDir(
|
|
stateDir: string,
|
|
deps?: {
|
|
platform?: NodeJS.Platform;
|
|
mountInfo?: string;
|
|
resolveRealPath?: (targetPath: string) => string | null;
|
|
resolveDeviceRealPath?: (targetPath: string) => string | null;
|
|
},
|
|
): LinuxSdBackedStateDir | null {
|
|
const platform = deps?.platform ?? process.platform;
|
|
if (platform !== "linux") {
|
|
return null;
|
|
}
|
|
const linuxPath = path.posix;
|
|
|
|
const resolveRealPath = deps?.resolveRealPath ?? tryResolveRealPath;
|
|
const resolvedStatePath = resolveRealPath(stateDir) ?? linuxPath.resolve(stateDir);
|
|
const mountInfo = deps?.mountInfo ?? tryReadLinuxMountInfo();
|
|
if (!mountInfo) {
|
|
return null;
|
|
}
|
|
|
|
const mountEntry = findLinuxMountInfoEntryForPath(
|
|
resolvedStatePath,
|
|
parseLinuxMountInfo(mountInfo),
|
|
linuxPath,
|
|
);
|
|
if (!mountEntry) {
|
|
return null;
|
|
}
|
|
|
|
const sourceCandidates = [mountEntry.source];
|
|
if (mountEntry.source.startsWith("/dev/")) {
|
|
const resolvedDevicePath = (deps?.resolveDeviceRealPath ?? tryResolveRealPath)(
|
|
mountEntry.source,
|
|
);
|
|
if (resolvedDevicePath) {
|
|
sourceCandidates.push(linuxPath.resolve(resolvedDevicePath));
|
|
}
|
|
}
|
|
if (!sourceCandidates.some((candidate) => isMmcDevicePath(candidate, linuxPath))) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
path: linuxPath.resolve(resolvedStatePath),
|
|
mountPoint: linuxPath.resolve(mountEntry.mountPoint),
|
|
fsType: mountEntry.fsType,
|
|
source: mountEntry.source,
|
|
};
|
|
}
|
|
|
|
export function formatLinuxSdBackedStateDirWarning(
|
|
displayStateDir: string,
|
|
linuxSdBackedStateDir: LinuxSdBackedStateDir,
|
|
): string {
|
|
const displayMountPoint =
|
|
linuxSdBackedStateDir.mountPoint === "/"
|
|
? "/"
|
|
: shortenHomePath(linuxSdBackedStateDir.mountPoint);
|
|
const safeSource = escapeControlCharsForTerminal(linuxSdBackedStateDir.source);
|
|
const safeFsType = escapeControlCharsForTerminal(linuxSdBackedStateDir.fsType);
|
|
const safeMountPoint = escapeControlCharsForTerminal(displayMountPoint);
|
|
return [
|
|
`- State directory appears to be on SD/eMMC storage (${displayStateDir}; device ${safeSource}, fs ${safeFsType}, mount ${safeMountPoint}).`,
|
|
"- SD/eMMC media can be slower for random I/O and wear faster under session/log churn.",
|
|
"- For better startup and state durability, prefer SSD/NVMe (or USB SSD on Raspberry Pi) for OPENCLAW_STATE_DIR.",
|
|
].join("\n");
|
|
}
|
|
|
|
export function detectMacCloudSyncedStateDir(
|
|
stateDir: string,
|
|
deps?: {
|
|
platform?: NodeJS.Platform;
|
|
homedir?: string;
|
|
resolveRealPath?: (targetPath: string) => string | null;
|
|
},
|
|
): {
|
|
path: string;
|
|
storage: "iCloud Drive" | "CloudStorage provider";
|
|
} | null {
|
|
const platform = deps?.platform ?? process.platform;
|
|
if (platform !== "darwin") {
|
|
return null;
|
|
}
|
|
|
|
// Cloud-sync roots should always be anchored to the OS account home on macOS.
|
|
// OPENCLAW_HOME can relocate app data defaults, but iCloud/CloudStorage remain under the OS home.
|
|
const homedir = deps?.homedir ?? os.homedir();
|
|
const roots = [
|
|
{
|
|
storage: "iCloud Drive" as const,
|
|
root: path.join(homedir, "Library", "Mobile Documents", "com~apple~CloudDocs"),
|
|
},
|
|
{
|
|
storage: "CloudStorage provider" as const,
|
|
root: path.join(homedir, "Library", "CloudStorage"),
|
|
},
|
|
];
|
|
const realPath = (deps?.resolveRealPath ?? tryResolveRealPath)(stateDir);
|
|
// Prefer the resolved target path when available so symlink prefixes do not
|
|
// misclassify local state dirs as cloud-synced.
|
|
const candidates = realPath ? [path.resolve(realPath)] : [path.resolve(stateDir)];
|
|
|
|
for (const candidate of candidates) {
|
|
for (const { storage, root } of roots) {
|
|
if (isPathUnderRoot(candidate, root)) {
|
|
return { path: candidate, storage };
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null;
|
|
}
|
|
|
|
function isPairingPolicy(value: unknown): boolean {
|
|
return typeof value === "string" && value.trim().toLowerCase() === "pairing";
|
|
}
|
|
|
|
function hasPairingPolicy(value: unknown): boolean {
|
|
if (!isRecord(value)) {
|
|
return false;
|
|
}
|
|
if (isPairingPolicy(value.dmPolicy)) {
|
|
return true;
|
|
}
|
|
if (isRecord(value.dm) && isPairingPolicy(value.dm.policy)) {
|
|
return true;
|
|
}
|
|
if (!isRecord(value.accounts)) {
|
|
return false;
|
|
}
|
|
for (const accountCfg of Object.values(value.accounts)) {
|
|
if (hasPairingPolicy(accountCfg)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function isSlashRoutingSessionKey(sessionKey: string): boolean {
|
|
const raw = sessionKey.trim().toLowerCase();
|
|
if (!raw) {
|
|
return false;
|
|
}
|
|
const scoped = parseAgentSessionKey(raw)?.rest ?? raw;
|
|
return /^[^:]+:slash:[^:]+(?:$|:)/.test(scoped);
|
|
}
|
|
|
|
function shouldRequireOAuthDir(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
|
|
if (env.OPENCLAW_OAUTH_DIR?.trim()) {
|
|
return true;
|
|
}
|
|
const channels = cfg.channels;
|
|
if (!isRecord(channels)) {
|
|
return false;
|
|
}
|
|
// WhatsApp auth always uses the credentials tree.
|
|
if (isRecord(channels.whatsapp)) {
|
|
return true;
|
|
}
|
|
// Pairing allowlists are persisted under credentials/<channel>-allowFrom.json.
|
|
for (const [channelId, channelCfg] of Object.entries(channels)) {
|
|
if (channelId === "defaults" || channelId === "modelByChannel") {
|
|
continue;
|
|
}
|
|
if (hasPairingPolicy(channelCfg)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export async function noteStateIntegrity(
|
|
cfg: OpenClawConfig,
|
|
prompter: DoctorPrompterLike,
|
|
configPath?: string,
|
|
) {
|
|
const warnings: string[] = [];
|
|
const changes: string[] = [];
|
|
const env = process.env;
|
|
const homedir = () => resolveRequiredHomeDir(env, os.homedir);
|
|
const stateDir = resolveStateDir(env, homedir);
|
|
const defaultStateDir = path.join(homedir(), ".openclaw");
|
|
const oauthDir = resolveOAuthDir(env, stateDir);
|
|
const agentId = resolveDefaultAgentId(cfg);
|
|
const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId, env, homedir);
|
|
const storePath = resolveStorePath(cfg.session?.store, { agentId });
|
|
const storeDir = path.dirname(storePath);
|
|
const absoluteStorePath = path.resolve(storePath);
|
|
const displayStateDir = shortenHomePath(stateDir);
|
|
const displayOauthDir = shortenHomePath(oauthDir);
|
|
const displaySessionsDir = shortenHomePath(sessionsDir);
|
|
const displayStoreDir = shortenHomePath(storeDir);
|
|
const displayConfigPath = configPath ? shortenHomePath(configPath) : undefined;
|
|
const requireOAuthDir = shouldRequireOAuthDir(cfg, env);
|
|
const cloudSyncedStateDir = detectMacCloudSyncedStateDir(stateDir);
|
|
const linuxSdBackedStateDir = detectLinuxSdBackedStateDir(stateDir);
|
|
|
|
if (cloudSyncedStateDir) {
|
|
warnings.push(
|
|
[
|
|
`- State directory is under macOS cloud-synced storage (${displayStateDir}; ${cloudSyncedStateDir.storage}).`,
|
|
"- This can cause slow I/O and sync/lock races for sessions and credentials.",
|
|
"- Prefer a local non-synced state dir (for example: ~/.openclaw).",
|
|
` Set locally: OPENCLAW_STATE_DIR=~/.openclaw ${formatCliCommand("openclaw doctor")}`,
|
|
].join("\n"),
|
|
);
|
|
}
|
|
if (linuxSdBackedStateDir) {
|
|
warnings.push(formatLinuxSdBackedStateDirWarning(displayStateDir, linuxSdBackedStateDir));
|
|
}
|
|
|
|
let stateDirExists = existsDir(stateDir);
|
|
if (!stateDirExists) {
|
|
warnings.push(
|
|
`- CRITICAL: state directory missing (${displayStateDir}). Sessions, credentials, logs, and config are stored there.`,
|
|
);
|
|
if (cfg.gateway?.mode === "remote") {
|
|
warnings.push(
|
|
"- Gateway is in remote mode; run doctor on the remote host where the gateway runs.",
|
|
);
|
|
}
|
|
const create = await prompter.confirmSkipInNonInteractive({
|
|
message: `Create ${displayStateDir} now?`,
|
|
initialValue: false,
|
|
});
|
|
if (create) {
|
|
const created = ensureDir(stateDir);
|
|
if (created.ok) {
|
|
changes.push(`- Created ${displayStateDir}`);
|
|
stateDirExists = true;
|
|
} else {
|
|
warnings.push(`- Failed to create ${displayStateDir}: ${created.error}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (stateDirExists && !canWriteDir(stateDir)) {
|
|
warnings.push(`- State directory not writable (${displayStateDir}).`);
|
|
const hint = dirPermissionHint(stateDir);
|
|
if (hint) {
|
|
warnings.push(` ${hint}`);
|
|
}
|
|
const repair = await prompter.confirmSkipInNonInteractive({
|
|
message: `Repair permissions on ${displayStateDir}?`,
|
|
initialValue: true,
|
|
});
|
|
if (repair) {
|
|
try {
|
|
const stat = fs.statSync(stateDir);
|
|
const target = addUserRwx(stat.mode);
|
|
fs.chmodSync(stateDir, target);
|
|
changes.push(`- Repaired permissions on ${displayStateDir}`);
|
|
} catch (err) {
|
|
warnings.push(`- Failed to repair ${displayStateDir}: ${String(err)}`);
|
|
}
|
|
}
|
|
}
|
|
if (stateDirExists && process.platform !== "win32") {
|
|
try {
|
|
const dirLstat = fs.lstatSync(stateDir);
|
|
const isDirSymlink = dirLstat.isSymbolicLink();
|
|
// For symlinks, check the resolved target permissions instead of the
|
|
// symlink itself (which always reports 777). Skip the warning only when
|
|
// the target lives in a known immutable store (e.g. /nix/store/).
|
|
const stat = isDirSymlink ? fs.statSync(stateDir) : dirLstat;
|
|
const resolvedDir = isDirSymlink ? fs.realpathSync(stateDir) : stateDir;
|
|
const isImmutableStore = resolvedDir.startsWith("/nix/store/");
|
|
if (!isImmutableStore && (stat.mode & 0o077) !== 0) {
|
|
warnings.push(
|
|
`- State directory permissions are too open (${displayStateDir}). Recommend chmod 700.`,
|
|
);
|
|
const tighten = await prompter.confirmSkipInNonInteractive({
|
|
message: `Tighten permissions on ${displayStateDir} to 700?`,
|
|
initialValue: true,
|
|
});
|
|
if (tighten) {
|
|
fs.chmodSync(stateDir, 0o700);
|
|
changes.push(`- Tightened permissions on ${displayStateDir} to 700`);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
warnings.push(`- Failed to read ${displayStateDir} permissions: ${String(err)}`);
|
|
}
|
|
}
|
|
|
|
if (configPath && existsFile(configPath) && process.platform !== "win32") {
|
|
try {
|
|
const configLstat = fs.lstatSync(configPath);
|
|
const isSymlink = configLstat.isSymbolicLink();
|
|
// For symlinks, check the resolved target permissions. Skip the warning
|
|
// only when the target lives in an immutable store (e.g. /nix/store/).
|
|
const stat = isSymlink ? fs.statSync(configPath) : configLstat;
|
|
const resolvedConfig = isSymlink ? fs.realpathSync(configPath) : configPath;
|
|
const isImmutableConfig = resolvedConfig.startsWith("/nix/store/");
|
|
if (!isImmutableConfig && (stat.mode & 0o077) !== 0) {
|
|
warnings.push(
|
|
`- Config file is group/world readable (${displayConfigPath ?? configPath}). Recommend chmod 600.`,
|
|
);
|
|
const tighten = await prompter.confirmSkipInNonInteractive({
|
|
message: `Tighten permissions on ${displayConfigPath ?? configPath} to 600?`,
|
|
initialValue: true,
|
|
});
|
|
if (tighten) {
|
|
fs.chmodSync(configPath, 0o600);
|
|
changes.push(`- Tightened permissions on ${displayConfigPath ?? configPath} to 600`);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
warnings.push(
|
|
`- Failed to read config permissions (${displayConfigPath ?? configPath}): ${String(err)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (stateDirExists) {
|
|
const dirCandidates = new Map<string, string>();
|
|
dirCandidates.set(sessionsDir, "Sessions dir");
|
|
dirCandidates.set(storeDir, "Session store dir");
|
|
if (requireOAuthDir) {
|
|
dirCandidates.set(oauthDir, "OAuth dir");
|
|
} else if (!existsDir(oauthDir)) {
|
|
warnings.push(
|
|
`- OAuth dir not present (${displayOauthDir}). Skipping create because no WhatsApp/pairing channel config is active.`,
|
|
);
|
|
}
|
|
const displayDirFor = (dir: string) => {
|
|
if (dir === sessionsDir) {
|
|
return displaySessionsDir;
|
|
}
|
|
if (dir === storeDir) {
|
|
return displayStoreDir;
|
|
}
|
|
if (dir === oauthDir) {
|
|
return displayOauthDir;
|
|
}
|
|
return shortenHomePath(dir);
|
|
};
|
|
|
|
for (const [dir, label] of dirCandidates) {
|
|
const displayDir = displayDirFor(dir);
|
|
if (!existsDir(dir)) {
|
|
warnings.push(`- CRITICAL: ${label} missing (${displayDir}).`);
|
|
const create = await prompter.confirmSkipInNonInteractive({
|
|
message: `Create ${label} at ${displayDir}?`,
|
|
initialValue: true,
|
|
});
|
|
if (create) {
|
|
const created = ensureDir(dir);
|
|
if (created.ok) {
|
|
changes.push(`- Created ${label}: ${displayDir}`);
|
|
} else {
|
|
warnings.push(`- Failed to create ${displayDir}: ${created.error}`);
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
if (!canWriteDir(dir)) {
|
|
warnings.push(`- ${label} not writable (${displayDir}).`);
|
|
const hint = dirPermissionHint(dir);
|
|
if (hint) {
|
|
warnings.push(` ${hint}`);
|
|
}
|
|
const repair = await prompter.confirmSkipInNonInteractive({
|
|
message: `Repair permissions on ${label}?`,
|
|
initialValue: true,
|
|
});
|
|
if (repair) {
|
|
try {
|
|
const stat = fs.statSync(dir);
|
|
const target = addUserRwx(stat.mode);
|
|
fs.chmodSync(dir, target);
|
|
changes.push(`- Repaired permissions on ${label}: ${displayDir}`);
|
|
} catch (err) {
|
|
warnings.push(`- Failed to repair ${displayDir}: ${String(err)}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const extraStateDirs = new Set<string>();
|
|
if (path.resolve(stateDir) !== path.resolve(defaultStateDir)) {
|
|
if (existsDir(defaultStateDir)) {
|
|
extraStateDirs.add(defaultStateDir);
|
|
}
|
|
}
|
|
for (const other of findOtherStateDirs(stateDir)) {
|
|
extraStateDirs.add(other);
|
|
}
|
|
if (extraStateDirs.size > 0) {
|
|
warnings.push(
|
|
[
|
|
"- Multiple state directories detected. This can split session history.",
|
|
...Array.from(extraStateDirs).map((dir) => ` - ${shortenHomePath(dir)}`),
|
|
` Active state dir: ${displayStateDir}`,
|
|
].join("\n"),
|
|
);
|
|
}
|
|
|
|
const store = loadSessionStore(storePath);
|
|
const sessionPathOpts = resolveSessionFilePathOptions({ agentId, storePath });
|
|
const entries = Object.entries(store).filter(([, entry]) => entry && typeof entry === "object");
|
|
if (entries.length > 0) {
|
|
const recent = entries
|
|
.slice()
|
|
.toSorted((a, b) => {
|
|
const aUpdated = typeof a[1].updatedAt === "number" ? a[1].updatedAt : 0;
|
|
const bUpdated = typeof b[1].updatedAt === "number" ? b[1].updatedAt : 0;
|
|
return bUpdated - aUpdated;
|
|
})
|
|
.slice(0, 5);
|
|
const recentTranscriptCandidates = recent.filter(([key]) => !isSlashRoutingSessionKey(key));
|
|
const missing = recentTranscriptCandidates.filter(([, entry]) => {
|
|
const sessionId = entry.sessionId;
|
|
if (!sessionId) {
|
|
return false;
|
|
}
|
|
const transcriptPath = resolveSessionFilePath(sessionId, entry, sessionPathOpts);
|
|
return !existsFile(transcriptPath);
|
|
});
|
|
if (missing.length > 0) {
|
|
warnings.push(
|
|
[
|
|
`- ${missing.length}/${recentTranscriptCandidates.length} recent sessions are missing transcripts.`,
|
|
` Verify sessions in store: ${formatCliCommand(`openclaw sessions --store "${absoluteStorePath}"`)}`,
|
|
` Preview cleanup impact: ${formatCliCommand(`openclaw sessions cleanup --store "${absoluteStorePath}" --dry-run`)}`,
|
|
` Prune missing entries: ${formatCliCommand(`openclaw sessions cleanup --store "${absoluteStorePath}" --enforce --fix-missing`)}`,
|
|
].join("\n"),
|
|
);
|
|
}
|
|
|
|
const mainKey = resolveMainSessionKey(cfg);
|
|
const mainEntry = store[mainKey];
|
|
if (mainEntry?.sessionId) {
|
|
const transcriptPath = resolveSessionFilePath(
|
|
mainEntry.sessionId,
|
|
mainEntry,
|
|
sessionPathOpts,
|
|
);
|
|
if (!existsFile(transcriptPath)) {
|
|
warnings.push(
|
|
`- Main session transcript missing (${shortenHomePath(transcriptPath)}). History will appear to reset.`,
|
|
);
|
|
} else {
|
|
const lineCount = countJsonlLines(transcriptPath);
|
|
if (lineCount <= 1) {
|
|
warnings.push(
|
|
`- Main session transcript has only ${lineCount} line. Session history may not be appending.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (existsDir(sessionsDir)) {
|
|
const referencedTranscriptPaths = new Set<string>();
|
|
for (const [, entry] of entries) {
|
|
if (!entry?.sessionId) {
|
|
continue;
|
|
}
|
|
try {
|
|
referencedTranscriptPaths.add(
|
|
path.resolve(resolveSessionFilePath(entry.sessionId, entry, sessionPathOpts)),
|
|
);
|
|
} catch {
|
|
// ignore invalid legacy paths
|
|
}
|
|
}
|
|
const sessionDirEntries = fs.readdirSync(sessionsDir, { withFileTypes: true });
|
|
const orphanTranscriptPaths = sessionDirEntries
|
|
.filter((entry) => entry.isFile() && isPrimarySessionTranscriptFileName(entry.name))
|
|
.map((entry) => path.resolve(path.join(sessionsDir, entry.name)))
|
|
.filter((filePath) => !referencedTranscriptPaths.has(filePath));
|
|
if (orphanTranscriptPaths.length > 0) {
|
|
warnings.push(
|
|
`- Found ${orphanTranscriptPaths.length} orphan transcript file(s) in ${displaySessionsDir}. They are not referenced by sessions.json and can consume disk over time.`,
|
|
);
|
|
const archiveOrphans = await prompter.confirmSkipInNonInteractive({
|
|
message: `Archive ${orphanTranscriptPaths.length} orphan transcript file(s) in ${displaySessionsDir}?`,
|
|
initialValue: false,
|
|
});
|
|
if (archiveOrphans) {
|
|
let archived = 0;
|
|
const archivedAt = formatSessionArchiveTimestamp();
|
|
for (const orphanPath of orphanTranscriptPaths) {
|
|
const archivedPath = `${orphanPath}.deleted.${archivedAt}`;
|
|
try {
|
|
fs.renameSync(orphanPath, archivedPath);
|
|
archived += 1;
|
|
} catch (err) {
|
|
warnings.push(
|
|
`- Failed to archive orphan transcript ${shortenHomePath(orphanPath)}: ${String(err)}`,
|
|
);
|
|
}
|
|
}
|
|
if (archived > 0) {
|
|
changes.push(`- Archived ${archived} orphan transcript file(s) in ${displaySessionsDir}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (warnings.length > 0) {
|
|
note(warnings.join("\n"), "State integrity");
|
|
}
|
|
if (changes.length > 0) {
|
|
note(changes.join("\n"), "Doctor changes");
|
|
}
|
|
}
|
|
|
|
export function noteWorkspaceBackupTip(workspaceDir: string) {
|
|
if (!existsDir(workspaceDir)) {
|
|
return;
|
|
}
|
|
const gitMarker = path.join(workspaceDir, ".git");
|
|
if (fs.existsSync(gitMarker)) {
|
|
return;
|
|
}
|
|
note(
|
|
[
|
|
"- Tip: back up the workspace in a private git repo (GitHub or GitLab).",
|
|
"- Keep ~/.openclaw out of git; it contains credentials and session history.",
|
|
"- Details: /concepts/agent-workspace#git-backup-recommended",
|
|
].join("\n"),
|
|
"Workspace",
|
|
);
|
|
}
|