diff --git a/src/config/paths.test.ts b/src/config/paths.test.ts index 6d2ffcfaf08..3ab428cc736 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, @@ -141,3 +142,59 @@ 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")); + }); + + 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(); + }); + + 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 a35a1a3d03d..ac2c3f7cb80 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,28 @@ 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))) { + // Backward compat: if a nested state dir already exists from the old + // buggy behavior, prefer it so we don't orphan existing state data. + // 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 + } + } + 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..d9e9021de16 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -139,6 +139,38 @@ 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")); + }); + + 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 caf5edb1969..8b6ec7aab5c 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,24 @@ 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))) { + // 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"); try { const hasNew = fs.existsSync(newDir); if (hasNew) {