From 456bd5874076dc6eb6fb778f18c9d2374e4cb130 Mon Sep 17 00:00:00 2001 From: Marcus Castro Date: Sun, 8 Feb 2026 22:06:29 -0300 Subject: [PATCH] fix(paths): structurally resolve home dir to prevent Windows path bugs (#12125) * fix(paths): structurally resolve home dir to prevent Windows path bugs Extract resolveRawHomeDir as a private function and gate the public resolveEffectiveHomeDir through a single path.resolve() exit point. This makes it structurally impossible for unresolved paths (missing drive letter on Windows) to escape the function, regardless of how many return paths exist in the raw lookup logic. Simplify resolveRequiredHomeDir to only resolve the process.cwd() fallback, since resolveEffectiveHomeDir now returns resolved values. Fix shortenMeta in tool-meta.ts: the colon-based split for file:line patterns (e.g. file.txt:12) conflicts with Windows drive letters (C:\...) because indexOf(":") matches the drive colon first. shortenHomeInString already handles file:line patterns correctly via split/join, so the colon split was both unnecessary and harmful. Update test assertions across all affected files to use path.resolve() in expected values and input strings so they match the now-correct resolved output on both Unix and Windows. Fixes #12119 * fix(changelog): add paths Windows fix entry (#12125) --------- Co-authored-by: Sebastian <19554889+sebslight@users.noreply.github.com> --- CHANGELOG.md | 1 + src/agents/agent-scope.test.ts | 8 +++-- src/agents/workspace.defaults.test.ts | 5 +++- src/agents/workspace.test.ts | 2 +- src/auto-reply/tool-meta.test.ts | 36 ++++++++++++---------- src/auto-reply/tool-meta.ts | 8 +---- src/cli/profile.test.ts | 7 +++-- src/config/paths.test.ts | 43 ++++++++++++++------------- src/gateway/session-utils.fs.test.ts | 4 ++- src/infra/home-dir.test.ts | 25 ++++++++++++---- src/infra/home-dir.ts | 8 ++++- src/utils.test.ts | 10 +++---- 12 files changed, 94 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4836f7e2cc..7b879f2b9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai - Config: clamp `maxTokens` to `contextWindow` to prevent invalid model configs. (#5516) Thanks @lailoo. - Docs: fix language switcher ordering and Japanese locale flag in Mintlify nav. (#12023) Thanks @joshp123. - Paths: make internal path resolution respect `HOME`/`USERPROFILE` before `os.homedir()` across config, agents, sessions, pairing, cron, and CLI profiles. (#12091) Thanks @sebslight. +- Paths: structurally resolve `OPENCLAW_HOME`-derived home paths and fix Windows drive-letter handling in tool meta shortening. (#12125) Thanks @mcaxtr. - Thinking: allow xhigh for `github-copilot/gpt-5.2-codex` and `github-copilot/gpt-5.2`. (#11646) Thanks @seans-openclawbot. - Discord: support forum/media `thread create` starter messages, wire `message thread create --message`, and harden thread-create routing. (#10062) Thanks @jarvis89757. - Gateway: stabilize chat routing by canonicalizing node session keys for node-originated chat methods. (#11755) Thanks @mbelinky. diff --git a/src/agents/agent-scope.test.ts b/src/agents/agent-scope.test.ts index 167748aa7b..487c0a9492 100644 --- a/src/agents/agent-scope.test.ts +++ b/src/agents/agent-scope.test.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { @@ -212,14 +213,17 @@ describe("resolveAgentConfig", () => { vi.stubEnv("OPENCLAW_HOME", "/srv/openclaw-home"); const workspace = resolveAgentWorkspaceDir({} as OpenClawConfig, "main"); - expect(workspace).toBe("/srv/openclaw-home/.openclaw/workspace"); + expect(workspace).toBe(path.join(path.resolve("/srv/openclaw-home"), ".openclaw", "workspace")); }); // Unix-style paths behave differently on Windows; skip there it.skipIf(process.platform === "win32")("uses OPENCLAW_HOME for default agentDir", () => { vi.stubEnv("OPENCLAW_HOME", "/srv/openclaw-home"); + vi.stubEnv("OPENCLAW_STATE_DIR", ""); const agentDir = resolveAgentDir({} as OpenClawConfig, "main"); - expect(agentDir).toBe("/srv/openclaw-home/.openclaw/agents/main/agent"); + expect(agentDir).toBe( + path.join(path.resolve("/srv/openclaw-home"), ".openclaw", "agents", "main", "agent"), + ); }); }); diff --git a/src/agents/workspace.defaults.test.ts b/src/agents/workspace.defaults.test.ts index c6f698cd93..1c9ff7fff0 100644 --- a/src/agents/workspace.defaults.test.ts +++ b/src/agents/workspace.defaults.test.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; afterEach(() => { @@ -13,6 +14,8 @@ describe("DEFAULT_AGENT_WORKSPACE_DIR", () => { vi.resetModules(); const mod = await import("./workspace.js"); - expect(mod.DEFAULT_AGENT_WORKSPACE_DIR).toBe("/srv/openclaw-home/.openclaw/workspace"); + expect(mod.DEFAULT_AGENT_WORKSPACE_DIR).toBe( + path.join(path.resolve("/srv/openclaw-home"), ".openclaw", "workspace"), + ); }); }); diff --git a/src/agents/workspace.test.ts b/src/agents/workspace.test.ts index b09e62ed13..d4f842e6ea 100644 --- a/src/agents/workspace.test.ts +++ b/src/agents/workspace.test.ts @@ -15,7 +15,7 @@ describe("resolveDefaultAgentWorkspaceDir", () => { HOME: "/home/other", } as NodeJS.ProcessEnv); - expect(dir).toBe(path.join("/srv/openclaw-home", ".openclaw", "workspace")); + expect(dir).toBe(path.join(path.resolve("/srv/openclaw-home"), ".openclaw", "workspace")); }); }); diff --git a/src/auto-reply/tool-meta.test.ts b/src/auto-reply/tool-meta.test.ts index 293a340ea6..68994267b1 100644 --- a/src/auto-reply/tool-meta.test.ts +++ b/src/auto-reply/tool-meta.test.ts @@ -1,31 +1,35 @@ +import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { formatToolAggregate, formatToolPrefix, shortenMeta, shortenPath } from "./tool-meta.js"; +// Use path.resolve so inputs match the resolved HOME on every platform. +const home = path.resolve("/Users/test"); + describe("tool meta formatting", () => { beforeEach(() => { vi.unstubAllEnvs(); }); it("shortens paths under HOME", () => { - vi.stubEnv("HOME", "/Users/test"); - expect(shortenPath("/Users/test")).toBe("~"); - expect(shortenPath("/Users/test/a/b.txt")).toBe("~/a/b.txt"); + vi.stubEnv("HOME", home); + expect(shortenPath(home)).toBe("~"); + expect(shortenPath(`${home}/a/b.txt`)).toBe("~/a/b.txt"); expect(shortenPath("/opt/x")).toBe("/opt/x"); }); it("shortens meta strings with optional colon suffix", () => { - vi.stubEnv("HOME", "/Users/test"); - expect(shortenMeta("/Users/test/a.txt")).toBe("~/a.txt"); - expect(shortenMeta("/Users/test/a.txt:12")).toBe("~/a.txt:12"); - expect(shortenMeta("cd /Users/test/dir && ls")).toBe("cd ~/dir && ls"); + vi.stubEnv("HOME", home); + expect(shortenMeta(`${home}/a.txt`)).toBe("~/a.txt"); + expect(shortenMeta(`${home}/a.txt:12`)).toBe("~/a.txt:12"); + expect(shortenMeta(`cd ${home}/dir && ls`)).toBe("cd ~/dir && ls"); expect(shortenMeta("")).toBe(""); }); it("formats aggregates with grouping and brace-collapse", () => { - vi.stubEnv("HOME", "/Users/test"); + vi.stubEnv("HOME", home); const out = formatToolAggregate(" fs ", [ - "/Users/test/dir/a.txt", - "/Users/test/dir/b.txt", + `${home}/dir/a.txt`, + `${home}/dir/b.txt`, "note", "a→b", ]); @@ -36,22 +40,22 @@ describe("tool meta formatting", () => { }); it("wraps aggregate meta in backticks when markdown is enabled", () => { - vi.stubEnv("HOME", "/Users/test"); - const out = formatToolAggregate("fs", ["/Users/test/dir/a.txt"], { markdown: true }); + vi.stubEnv("HOME", home); + const out = formatToolAggregate("fs", [`${home}/dir/a.txt`], { markdown: true }); expect(out).toContain("`~/dir/a.txt`"); }); it("keeps exec flags outside markdown and moves them to the front", () => { - vi.stubEnv("HOME", "/Users/test"); - const out = formatToolAggregate("exec", ["cd /Users/test/dir && gemini 2>&1 · elevated"], { + vi.stubEnv("HOME", home); + const out = formatToolAggregate("exec", [`cd ${home}/dir && gemini 2>&1 · elevated`], { markdown: true, }); expect(out).toBe("🛠️ Exec: elevated · `cd ~/dir && gemini 2>&1`"); }); it("formats prefixes with default labels", () => { - vi.stubEnv("HOME", "/Users/test"); + vi.stubEnv("HOME", home); expect(formatToolPrefix(undefined, undefined)).toBe("🧩 Tool"); - expect(formatToolPrefix("x", "/Users/test/a.txt")).toBe("🧩 X: ~/a.txt"); + expect(formatToolPrefix("x", `${home}/a.txt`)).toBe("🧩 X: ~/a.txt"); }); }); diff --git a/src/auto-reply/tool-meta.ts b/src/auto-reply/tool-meta.ts index 4297741f81..ce929284ec 100644 --- a/src/auto-reply/tool-meta.ts +++ b/src/auto-reply/tool-meta.ts @@ -13,13 +13,7 @@ export function shortenMeta(meta: string): string { if (!meta) { return meta; } - const colonIdx = meta.indexOf(":"); - if (colonIdx === -1) { - return shortenHomeInString(meta); - } - const base = meta.slice(0, colonIdx); - const rest = meta.slice(colonIdx); - return `${shortenHomeInString(base)}${rest}`; + return shortenHomeInString(meta); } export function formatToolAggregate( diff --git a/src/cli/profile.test.ts b/src/cli/profile.test.ts index 812417b819..5c78eaa367 100644 --- a/src/cli/profile.test.ts +++ b/src/cli/profile.test.ts @@ -61,7 +61,7 @@ describe("applyCliProfileEnv", () => { env, homedir: () => "/home/peter", }); - const expectedStateDir = path.join("/home/peter", ".openclaw-dev"); + const expectedStateDir = path.join(path.resolve("/home/peter"), ".openclaw-dev"); expect(env.OPENCLAW_PROFILE).toBe("dev"); expect(env.OPENCLAW_STATE_DIR).toBe(expectedStateDir); expect(env.OPENCLAW_CONFIG_PATH).toBe(path.join(expectedStateDir, "openclaw.json")); @@ -94,9 +94,10 @@ describe("applyCliProfileEnv", () => { homedir: () => "/home/fallback", }); - expect(env.OPENCLAW_STATE_DIR).toBe(path.join("/srv/openclaw-home", ".openclaw-work")); + const resolvedHome = path.resolve("/srv/openclaw-home"); + expect(env.OPENCLAW_STATE_DIR).toBe(path.join(resolvedHome, ".openclaw-work")); expect(env.OPENCLAW_CONFIG_PATH).toBe( - path.join("/srv/openclaw-home", ".openclaw-work", "openclaw.json"), + path.join(resolvedHome, ".openclaw-work", "openclaw.json"), ); }); }); diff --git a/src/config/paths.test.ts b/src/config/paths.test.ts index 443bc2ab7e..fb956a10a7 100644 --- a/src/config/paths.test.ts +++ b/src/config/paths.test.ts @@ -49,10 +49,11 @@ describe("state + config path candidates", () => { OPENCLAW_HOME: "/srv/openclaw-home", } as NodeJS.ProcessEnv; - expect(resolveStateDir(env)).toBe(path.join("/srv/openclaw-home", ".openclaw")); + const resolvedHome = path.resolve("/srv/openclaw-home"); + expect(resolveStateDir(env)).toBe(path.join(resolvedHome, ".openclaw")); const candidates = resolveDefaultConfigCandidates(env); - expect(candidates[0]).toBe(path.join("/srv/openclaw-home", ".openclaw", "openclaw.json")); + expect(candidates[0]).toBe(path.join(resolvedHome, ".openclaw", "openclaw.json")); }); it("prefers OPENCLAW_HOME over HOME for default state/config locations", () => { @@ -61,32 +62,34 @@ describe("state + config path candidates", () => { HOME: "/home/other", } as NodeJS.ProcessEnv; - expect(resolveStateDir(env)).toBe(path.join("/srv/openclaw-home", ".openclaw")); + const resolvedHome = path.resolve("/srv/openclaw-home"); + expect(resolveStateDir(env)).toBe(path.join(resolvedHome, ".openclaw")); const candidates = resolveDefaultConfigCandidates(env); - expect(candidates[0]).toBe(path.join("/srv/openclaw-home", ".openclaw", "openclaw.json")); + expect(candidates[0]).toBe(path.join(resolvedHome, ".openclaw", "openclaw.json")); }); it("orders default config candidates in a stable order", () => { const home = "/home/test"; + const resolvedHome = path.resolve(home); const candidates = resolveDefaultConfigCandidates({} as NodeJS.ProcessEnv, () => home); const expected = [ - path.join(home, ".openclaw", "openclaw.json"), - path.join(home, ".openclaw", "clawdbot.json"), - path.join(home, ".openclaw", "moltbot.json"), - path.join(home, ".openclaw", "moldbot.json"), - path.join(home, ".clawdbot", "openclaw.json"), - path.join(home, ".clawdbot", "clawdbot.json"), - path.join(home, ".clawdbot", "moltbot.json"), - path.join(home, ".clawdbot", "moldbot.json"), - path.join(home, ".moltbot", "openclaw.json"), - path.join(home, ".moltbot", "clawdbot.json"), - path.join(home, ".moltbot", "moltbot.json"), - path.join(home, ".moltbot", "moldbot.json"), - path.join(home, ".moldbot", "openclaw.json"), - path.join(home, ".moldbot", "clawdbot.json"), - path.join(home, ".moldbot", "moltbot.json"), - path.join(home, ".moldbot", "moldbot.json"), + path.join(resolvedHome, ".openclaw", "openclaw.json"), + path.join(resolvedHome, ".openclaw", "clawdbot.json"), + path.join(resolvedHome, ".openclaw", "moltbot.json"), + path.join(resolvedHome, ".openclaw", "moldbot.json"), + path.join(resolvedHome, ".clawdbot", "openclaw.json"), + path.join(resolvedHome, ".clawdbot", "clawdbot.json"), + path.join(resolvedHome, ".clawdbot", "moltbot.json"), + path.join(resolvedHome, ".clawdbot", "moldbot.json"), + path.join(resolvedHome, ".moltbot", "openclaw.json"), + path.join(resolvedHome, ".moltbot", "clawdbot.json"), + path.join(resolvedHome, ".moltbot", "moltbot.json"), + path.join(resolvedHome, ".moltbot", "moldbot.json"), + path.join(resolvedHome, ".moldbot", "openclaw.json"), + path.join(resolvedHome, ".moldbot", "clawdbot.json"), + path.join(resolvedHome, ".moldbot", "moltbot.json"), + path.join(resolvedHome, ".moldbot", "moldbot.json"), ]; expect(candidates).toEqual(expected); }); diff --git a/src/gateway/session-utils.fs.test.ts b/src/gateway/session-utils.fs.test.ts index 83b49bb564..e465999b16 100644 --- a/src/gateway/session-utils.fs.test.ts +++ b/src/gateway/session-utils.fs.test.ts @@ -502,6 +502,8 @@ describe("resolveSessionTranscriptCandidates", () => { const candidates = resolveSessionTranscriptCandidates("sess-1", undefined); const fallback = candidates[candidates.length - 1]; - expect(fallback).toBe(path.join("/srv/openclaw-home", ".openclaw", "sessions", "sess-1.jsonl")); + expect(fallback).toBe( + path.join(path.resolve("/srv/openclaw-home"), ".openclaw", "sessions", "sess-1.jsonl"), + ); }); }); diff --git a/src/infra/home-dir.test.ts b/src/infra/home-dir.test.ts index b4cee701f6..f1f721cd7f 100644 --- a/src/infra/home-dir.test.ts +++ b/src/infra/home-dir.test.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { describe, expect, it } from "vitest"; import { expandHomePrefix, resolveEffectiveHomeDir, resolveRequiredHomeDir } from "./home-dir.js"; @@ -9,17 +10,21 @@ describe("resolveEffectiveHomeDir", () => { USERPROFILE: "C:/Users/other", } as NodeJS.ProcessEnv; - expect(resolveEffectiveHomeDir(env, () => "/fallback")).toBe("/srv/openclaw-home"); + expect(resolveEffectiveHomeDir(env, () => "/fallback")).toBe( + path.resolve("/srv/openclaw-home"), + ); }); it("falls back to HOME then USERPROFILE then homedir", () => { expect(resolveEffectiveHomeDir({ HOME: "/home/alice" } as NodeJS.ProcessEnv)).toBe( - "/home/alice", + path.resolve("/home/alice"), ); expect(resolveEffectiveHomeDir({ USERPROFILE: "C:/Users/alice" } as NodeJS.ProcessEnv)).toBe( - "C:/Users/alice", + path.resolve("C:/Users/alice"), + ); + expect(resolveEffectiveHomeDir({} as NodeJS.ProcessEnv, () => "/fallback")).toBe( + path.resolve("/fallback"), ); - expect(resolveEffectiveHomeDir({} as NodeJS.ProcessEnv, () => "/fallback")).toBe("/fallback"); }); it("expands OPENCLAW_HOME when set to ~", () => { @@ -28,7 +33,7 @@ describe("resolveEffectiveHomeDir", () => { HOME: "/home/alice", } as NodeJS.ProcessEnv; - expect(resolveEffectiveHomeDir(env)).toBe("/home/alice/svc"); + expect(resolveEffectiveHomeDir(env)).toBe(path.resolve("/home/alice/svc")); }); }); @@ -41,6 +46,14 @@ describe("resolveRequiredHomeDir", () => { ).toBe(process.cwd()); }); + it("returns a fully resolved path for OPENCLAW_HOME", () => { + const result = resolveRequiredHomeDir( + { OPENCLAW_HOME: "/custom/home" } as NodeJS.ProcessEnv, + () => "/fallback", + ); + expect(result).toBe(path.resolve("/custom/home")); + }); + it("returns cwd when OPENCLAW_HOME is tilde-only and no fallback home exists", () => { expect( resolveRequiredHomeDir({ OPENCLAW_HOME: "~" } as NodeJS.ProcessEnv, () => { @@ -55,7 +68,7 @@ describe("expandHomePrefix", () => { const value = expandHomePrefix("~/x", { env: { OPENCLAW_HOME: "/srv/openclaw-home" } as NodeJS.ProcessEnv, }); - expect(value).toBe("/srv/openclaw-home/x"); + expect(value).toBe(`${path.resolve("/srv/openclaw-home")}/x`); }); it("keeps non-tilde values unchanged", () => { diff --git a/src/infra/home-dir.ts b/src/infra/home-dir.ts index c698dd19b3..7dd2bbdd1e 100644 --- a/src/infra/home-dir.ts +++ b/src/infra/home-dir.ts @@ -1,4 +1,5 @@ import os from "node:os"; +import path from "node:path"; function normalize(value: string | undefined): string | undefined { const trimmed = value?.trim(); @@ -9,6 +10,11 @@ export function resolveEffectiveHomeDir( env: NodeJS.ProcessEnv = process.env, homedir: () => string = os.homedir, ): string | undefined { + const raw = resolveRawHomeDir(env, homedir); + return raw ? path.resolve(raw) : undefined; +} + +function resolveRawHomeDir(env: NodeJS.ProcessEnv, homedir: () => string): string | undefined { const explicitHome = normalize(env.OPENCLAW_HOME); if (explicitHome) { if (explicitHome === "~" || explicitHome.startsWith("~/") || explicitHome.startsWith("~\\")) { @@ -47,7 +53,7 @@ export function resolveRequiredHomeDir( env: NodeJS.ProcessEnv = process.env, homedir: () => string = os.homedir, ): string { - return resolveEffectiveHomeDir(env, homedir) ?? process.cwd(); + return resolveEffectiveHomeDir(env, homedir) ?? path.resolve(process.cwd()); } export function expandHomePrefix( diff --git a/src/utils.test.ts b/src/utils.test.ts index 9bf6e00cff..163470090f 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -142,7 +142,7 @@ describe("resolveHomeDir", () => { vi.stubEnv("OPENCLAW_HOME", "/srv/openclaw-home"); vi.stubEnv("HOME", "/home/other"); - expect(resolveHomeDir()).toBe("/srv/openclaw-home"); + expect(resolveHomeDir()).toBe(path.resolve("/srv/openclaw-home")); vi.unstubAllEnvs(); }); @@ -153,7 +153,7 @@ describe("shortenHomePath", () => { vi.stubEnv("OPENCLAW_HOME", "/srv/openclaw-home"); vi.stubEnv("HOME", "/home/other"); - expect(shortenHomePath("/srv/openclaw-home/.openclaw/openclaw.json")).toBe( + expect(shortenHomePath(`${path.resolve("/srv/openclaw-home")}/.openclaw/openclaw.json`)).toBe( "$OPENCLAW_HOME/.openclaw/openclaw.json", ); @@ -166,9 +166,9 @@ describe("shortenHomeInString", () => { vi.stubEnv("OPENCLAW_HOME", "/srv/openclaw-home"); vi.stubEnv("HOME", "/home/other"); - expect(shortenHomeInString("config: /srv/openclaw-home/.openclaw/openclaw.json")).toBe( - "config: $OPENCLAW_HOME/.openclaw/openclaw.json", - ); + expect( + shortenHomeInString(`config: ${path.resolve("/srv/openclaw-home")}/.openclaw/openclaw.json`), + ).toBe("config: $OPENCLAW_HOME/.openclaw/openclaw.json"); vi.unstubAllEnvs(); });