From 9e38b064e95a4af57d3e8214ee222dc43220e665 Mon Sep 17 00:00:00 2001 From: Jerry-Xin Date: Sat, 14 Mar 2026 18:58:23 +0800 Subject: [PATCH 1/4] fix(config): prevent nested .openclaw directory when OPENCLAW_HOME is set When OPENCLAW_HOME is explicitly set to a path ending with a known state directory name (.openclaw, .clawdbot, .moldbot, .moltbot), both resolveStateDir() and resolveConfigDir() now return the path directly without appending another .openclaw segment. This fixes the nested directory bug where OPENCLAW_HOME=~/.openclaw would produce ~/.openclaw/.openclaw instead of ~/.openclaw. Fixes #45765 --- src/config/paths.test.ts | 32 ++++++++++++++++++++++++++++++++ src/config/paths.ts | 15 +++++++++++++++ src/utils.test.ts | 20 ++++++++++++++++++++ src/utils.ts | 11 +++++++++-- 4 files changed, 76 insertions(+), 2 deletions(-) diff --git a/src/config/paths.test.ts b/src/config/paths.test.ts index 6d2ffcfaf08..ab16f26fb1a 100644 --- a/src/config/paths.test.ts +++ b/src/config/paths.test.ts @@ -141,3 +141,35 @@ describe("state + config path candidates", () => { }); }); }); + +describe("resolveStateDir nesting guard (#45765)", () => { + it("does not append .openclaw when OPENCLAW_HOME ends with .openclaw", () => { + const env = { OPENCLAW_HOME: "/home/user/.openclaw" } as NodeJS.ProcessEnv; + expect(resolveStateDir(env)).toBe(path.resolve("/home/user/.openclaw")); + }); + + it("does not append .openclaw when OPENCLAW_HOME ends with .clawdbot", () => { + const env = { OPENCLAW_HOME: "/home/user/.clawdbot" } as NodeJS.ProcessEnv; + expect(resolveStateDir(env)).toBe(path.resolve("/home/user/.clawdbot")); + }); + + it("does not append .openclaw when OPENCLAW_HOME ends with .moldbot", () => { + const env = { OPENCLAW_HOME: "/home/user/.moldbot" } as NodeJS.ProcessEnv; + expect(resolveStateDir(env)).toBe(path.resolve("/home/user/.moldbot")); + }); + + it("handles tilde expansion when OPENCLAW_HOME=~/.openclaw", () => { + const env = { OPENCLAW_HOME: "~/.openclaw", HOME: "/home/user" } as NodeJS.ProcessEnv; + expect(resolveStateDir(env)).toBe(path.resolve("/home/user/.openclaw")); + }); + + it("still appends .openclaw when OPENCLAW_HOME is not a state dir basename", () => { + const env = { OPENCLAW_HOME: "/srv/app" } as NodeJS.ProcessEnv; + expect(resolveStateDir(env)).toBe(path.resolve("/srv/app/.openclaw")); + }); + + it("still appends .openclaw when OPENCLAW_HOME is not set", () => { + const env = { HOME: "/home/user" } as NodeJS.ProcessEnv; + expect(resolveStateDir(env)).toBe(path.resolve("/home/user/.openclaw")); + }); +}); diff --git a/src/config/paths.ts b/src/config/paths.ts index 84c27749bcf..1240e1ba3c7 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -21,6 +21,12 @@ export const isNixMode = resolveIsNixMode(); const LEGACY_STATE_DIRNAMES = [".clawdbot", ".moldbot", ".moltbot"] as const; const NEW_STATE_DIRNAME = ".openclaw"; const CONFIG_FILENAME = "openclaw.json"; + +/** All recognized state directory basenames (for nesting guard). */ +export const ALL_STATE_DIRNAMES: ReadonlySet = new Set([ + NEW_STATE_DIRNAME, + ...LEGACY_STATE_DIRNAMES, +]); const LEGACY_CONFIG_FILENAMES = ["clawdbot.json", "moldbot.json", "moltbot.json"] as const; function resolveDefaultHomeDir(): string { @@ -66,6 +72,15 @@ export function resolveStateDir( if (override) { return resolveUserPath(override, env, effectiveHomedir); } + // Nesting guard: when OPENCLAW_HOME is explicitly set and its basename is + // already a known state directory name, use it directly without appending. + const explicitHome = env.OPENCLAW_HOME?.trim(); + if (explicitHome) { + const resolvedHome = effectiveHomedir(); + if (ALL_STATE_DIRNAMES.has(path.basename(resolvedHome))) { + return resolvedHome; + } + } const newDir = newStateDir(effectiveHomedir); if (env.OPENCLAW_TEST_FAST === "1") { return newDir; diff --git a/src/utils.test.ts b/src/utils.test.ts index 8880f41f6b1..4bb3b16422a 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -139,6 +139,26 @@ describe("resolveConfigDir", () => { expect(resolveConfigDir(env)).toBe(path.resolve("/tmp/openclaw-home", "state")); }); + + it("does not append .openclaw when OPENCLAW_HOME ends with .openclaw (#45765)", () => { + const env = { OPENCLAW_HOME: "/home/user/.openclaw" } as NodeJS.ProcessEnv; + expect(resolveConfigDir(env)).toBe(path.resolve("/home/user/.openclaw")); + }); + + it("does not append .openclaw when OPENCLAW_HOME ends with .clawdbot (#45765)", () => { + const env = { OPENCLAW_HOME: "/home/user/.clawdbot" } as NodeJS.ProcessEnv; + expect(resolveConfigDir(env)).toBe(path.resolve("/home/user/.clawdbot")); + }); + + it("handles tilde expansion when OPENCLAW_HOME=~/.openclaw (#45765)", () => { + const env = { OPENCLAW_HOME: "~/.openclaw", HOME: "/home/user" } as NodeJS.ProcessEnv; + expect(resolveConfigDir(env)).toBe(path.resolve("/home/user/.openclaw")); + }); + + it("still appends .openclaw when OPENCLAW_HOME is not a state dir basename", () => { + const env = { OPENCLAW_HOME: "/srv/app" } as NodeJS.ProcessEnv; + expect(resolveConfigDir(env)).toBe(path.resolve("/srv/app/.openclaw")); + }); }); describe("resolveHomeDir", () => { diff --git a/src/utils.ts b/src/utils.ts index caf5edb1969..158e4ff080c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { resolveOAuthDir } from "./config/paths.js"; +import { ALL_STATE_DIRNAMES, resolveOAuthDir } from "./config/paths.js"; import { logVerbose, shouldLogVerbose } from "./globals.js"; import { resolveEffectiveHomeDir, @@ -290,7 +290,14 @@ export function resolveConfigDir( if (override) { return resolveUserPath(override, env, homedir); } - const newDir = path.join(resolveRequiredHomeDir(env, homedir), ".openclaw"); + // Nesting guard: when OPENCLAW_HOME is explicitly set and its basename is + // already a known state directory name, use it directly without appending. + const explicitHome = env.OPENCLAW_HOME?.trim(); + const resolvedHome = resolveRequiredHomeDir(env, homedir); + if (explicitHome && ALL_STATE_DIRNAMES.has(path.basename(resolvedHome))) { + return resolvedHome; + } + const newDir = path.join(resolvedHome, ".openclaw"); try { const hasNew = fs.existsSync(newDir); if (hasNew) { From 14fb5801c54285c4357a80502255f52d44628a3b Mon Sep 17 00:00:00 2001 From: Jerry-Xin Date: Sat, 14 Mar 2026 22:06:39 +0800 Subject: [PATCH 2/4] fix(config): preserve existing nested config dir before short-circuiting When OPENCLAW_HOME basename matches a known state dir name, the nesting guard now checks whether a nested .openclaw subdirectory already exists before returning the parent. This preserves backward compatibility for installs that previously wrote config data into the (now-prevented) nested path. Adds test to verify the backward compat behavior. --- src/utils.test.ts | 12 ++++++++++++ src/utils.ts | 10 ++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/utils.test.ts b/src/utils.test.ts index 4bb3b16422a..d9e9021de16 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -159,6 +159,18 @@ describe("resolveConfigDir", () => { const env = { OPENCLAW_HOME: "/srv/app" } as NodeJS.ProcessEnv; expect(resolveConfigDir(env)).toBe(path.resolve("/srv/app/.openclaw")); }); + + it("prefers existing nested config dir for backward compat (#45765)", () => { + const tmpDir = path.join(os.tmpdir(), `openclaw-test-config-${Date.now()}`); + const nestedDir = path.join(tmpDir, ".openclaw", ".openclaw"); + fs.mkdirSync(nestedDir, { recursive: true }); + try { + const env = { OPENCLAW_HOME: path.join(tmpDir, ".openclaw") } as NodeJS.ProcessEnv; + expect(resolveConfigDir(env)).toBe(path.join(tmpDir, ".openclaw", ".openclaw")); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); }); describe("resolveHomeDir", () => { diff --git a/src/utils.ts b/src/utils.ts index 158e4ff080c..8b6ec7aab5c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -295,6 +295,16 @@ export function resolveConfigDir( const explicitHome = env.OPENCLAW_HOME?.trim(); const resolvedHome = resolveRequiredHomeDir(env, homedir); if (explicitHome && ALL_STATE_DIRNAMES.has(path.basename(resolvedHome))) { + // Backward compat: if a nested config dir already exists from the old + // buggy behavior, prefer it so we don't orphan existing config data. + const nestedConfig = path.join(resolvedHome, ".openclaw"); + try { + if (fs.existsSync(nestedConfig)) { + return nestedConfig; + } + } catch { + // best-effort + } return resolvedHome; } const newDir = path.join(resolvedHome, ".openclaw"); From d1b488c5a166f5c164cb6e03d0d9758792bac192 Mon Sep 17 00:00:00 2001 From: Jerry-Xin Date: Sun, 15 Mar 2026 00:05:33 +0800 Subject: [PATCH 3/4] fix(config): preserve existing nested state dir before short-circuiting When OPENCLAW_HOME basename matches a known state dir name, the nesting guard in resolveStateDir now checks whether a nested .openclaw subdirectory already exists before returning the parent. This preserves backward compatibility for installs that previously wrote state data into the (now-prevented) nested path. Mirrors the same fix already applied to resolveConfigDir in utils.ts. --- src/config/paths.test.ts | 11 ++++++++++- src/config/paths.ts | 10 ++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/config/paths.test.ts b/src/config/paths.test.ts index ab16f26fb1a..b72f7b37590 100644 --- a/src/config/paths.test.ts +++ b/src/config/paths.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; +import fsSync from "node:fs"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { withTempDir } from "../test-helpers/temp-dir.js"; import { resolveDefaultConfigCandidates, @@ -172,4 +173,12 @@ describe("resolveStateDir nesting guard (#45765)", () => { const env = { HOME: "/home/user" } as NodeJS.ProcessEnv; expect(resolveStateDir(env)).toBe(path.resolve("/home/user/.openclaw")); }); + + it("preserves existing nested state dir for backward compat", () => { + const env = { OPENCLAW_HOME: "/home/user/.openclaw" } as NodeJS.ProcessEnv; + const nestedDir = path.resolve("/home/user/.openclaw/.openclaw"); + vi.spyOn(fsSync, "existsSync").mockImplementation((p) => String(p) === nestedDir); + expect(resolveStateDir(env)).toBe(nestedDir); + vi.restoreAllMocks(); + }); }); diff --git a/src/config/paths.ts b/src/config/paths.ts index 1240e1ba3c7..c35c3ffcf94 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -78,6 +78,16 @@ export function resolveStateDir( if (explicitHome) { const resolvedHome = effectiveHomedir(); if (ALL_STATE_DIRNAMES.has(path.basename(resolvedHome))) { + // Backward compat: if a nested state dir already exists from the old + // buggy behavior, prefer it so we don't orphan existing state data. + const nestedState = path.join(resolvedHome, ".openclaw"); + try { + if (fs.existsSync(nestedState)) { + return nestedState; + } + } catch { + // best-effort + } return resolvedHome; } } From 52b13ac101cf8f2d1147af4c9c8f7d63ada5e16a Mon Sep 17 00:00:00 2001 From: Jerry-Xin Date: Mon, 16 Mar 2026 17:15:53 +0800 Subject: [PATCH 4/4] fix(config): check all legacy nested dirs when OPENCLAW_HOME ends in state basename --- src/config/paths.test.ts | 16 ++++++++++++++++ src/config/paths.ts | 15 +++++++++------ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/config/paths.test.ts b/src/config/paths.test.ts index b72f7b37590..3ab428cc736 100644 --- a/src/config/paths.test.ts +++ b/src/config/paths.test.ts @@ -181,4 +181,20 @@ describe("resolveStateDir nesting guard (#45765)", () => { expect(resolveStateDir(env)).toBe(nestedDir); vi.restoreAllMocks(); }); + + it("preserves existing nested legacy dir when OPENCLAW_HOME ends in legacy basename", () => { + const env = { OPENCLAW_HOME: "/home/user/.clawdbot" } as NodeJS.ProcessEnv; + const nestedDir = path.resolve("/home/user/.clawdbot/.clawdbot"); + vi.spyOn(fsSync, "existsSync").mockImplementation((p) => String(p) === nestedDir); + expect(resolveStateDir(env)).toBe(nestedDir); + vi.restoreAllMocks(); + }); + + it("preserves nested .moldbot when OPENCLAW_HOME ends in .clawdbot", () => { + const env = { OPENCLAW_HOME: "/home/user/.clawdbot" } as NodeJS.ProcessEnv; + const nestedDir = path.resolve("/home/user/.clawdbot/.moldbot"); + vi.spyOn(fsSync, "existsSync").mockImplementation((p) => String(p) === nestedDir); + expect(resolveStateDir(env)).toBe(nestedDir); + vi.restoreAllMocks(); + }); }); diff --git a/src/config/paths.ts b/src/config/paths.ts index c35c3ffcf94..362c237f4ed 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -80,13 +80,16 @@ export function resolveStateDir( if (ALL_STATE_DIRNAMES.has(path.basename(resolvedHome))) { // Backward compat: if a nested state dir already exists from the old // buggy behavior, prefer it so we don't orphan existing state data. - const nestedState = path.join(resolvedHome, ".openclaw"); - try { - if (fs.existsSync(nestedState)) { - return nestedState; + // Check all known state dirnames, not just .openclaw. + for (const nestedName of ALL_STATE_DIRNAMES) { + const nestedState = path.join(resolvedHome, nestedName); + try { + if (fs.existsSync(nestedState)) { + return nestedState; + } + } catch { + // best-effort } - } catch { - // best-effort } return resolvedHome; }