diff --git a/apps/memos-local-plugin/adapters/hermes/memos_provider/__init__.py b/apps/memos-local-plugin/adapters/hermes/memos_provider/__init__.py index 79b88cbf4..4b293426a 100644 --- a/apps/memos-local-plugin/adapters/hermes/memos_provider/__init__.py +++ b/apps/memos-local-plugin/adapters/hermes/memos_provider/__init__.py @@ -71,6 +71,7 @@ ensure_viewer_daemon, kill_zombie_bridges, ) +from hermes_home import resolve_hermes_home # noqa: E402 from shared_bridge_runtime import ( # noqa: E402 HERMES_HOOK_DISPATCHER, SHARED_BRIDGE_REGISTRY, @@ -129,7 +130,9 @@ def _resolved_memos_runtime_home() -> Path: config_file = os.environ.get("MEMOS_CONFIG_FILE", "").strip() if config_file: return Path(config_file).expanduser().resolve().parent - return (Path.home() / ".hermes" / "memos-plugin").resolve() + # Fallback: use the canonical Hermes home (HERMES_HOME → LOCALAPPDATA → + # ~/.hermes on POSIX). See hermes_home.resolve_hermes_home and issue #2221. + return (resolve_hermes_home() / "memos-plugin").resolve() def _memos_runtime_env_snapshot(runtime_home: Path | None = None) -> dict[str, str]: @@ -1291,7 +1294,7 @@ def _extract_child_tool_calls(self, child_session_id: str = "") -> list[dict[str sessions_dir = ( Path(self._hermes_home).expanduser() / "sessions" if self._hermes_home - else Path.home() / ".hermes" / "sessions" + else resolve_hermes_home() / "sessions" ) session_path = sessions_dir / f"session_{child_session_id}.json" try: diff --git a/apps/memos-local-plugin/adapters/hermes/memos_provider/bridge_client.py b/apps/memos-local-plugin/adapters/hermes/memos_provider/bridge_client.py index cbb2fa84c..38fe6c961 100644 --- a/apps/memos-local-plugin/adapters/hermes/memos_provider/bridge_client.py +++ b/apps/memos-local-plugin/adapters/hermes/memos_provider/bridge_client.py @@ -25,6 +25,8 @@ from pathlib import Path from typing import TYPE_CHECKING, Any +from hermes_home import resolve_hermes_home + if TYPE_CHECKING: from collections.abc import Callable @@ -66,8 +68,15 @@ def _resolved_runtime_home(agent: str, env: dict[str, str]) -> Path: config_file = env.get("MEMOS_CONFIG_FILE", "").strip() if config_file: return _expanded_path(config_file, env).parent - agent_home = ".hermes" if agent == "hermes" else f".{agent}" - default_home = Path(env.get("HOME", "") or Path.home()) / agent_home / "memos-plugin" + # Hermes gets a platform-aware home (HERMES_HOME → LOCALAPPDATA on + # Windows → ~/.hermes on POSIX). Other agents keep the ~/. + # convention because they do not participate in the HERMES_HOME + # contract. See issue #2221. + if agent == "hermes": + default_home = resolve_hermes_home(env=env) / "memos-plugin" + else: + agent_home = f".{agent}" + default_home = Path(env.get("HOME", "") or Path.home()) / agent_home / "memos-plugin" return _expanded_path(str(default_home), env) diff --git a/apps/memos-local-plugin/adapters/hermes/memos_provider/hermes_home.py b/apps/memos-local-plugin/adapters/hermes/memos_provider/hermes_home.py new file mode 100644 index 000000000..1da32261f --- /dev/null +++ b/apps/memos-local-plugin/adapters/hermes/memos_provider/hermes_home.py @@ -0,0 +1,102 @@ +"""Canonical Hermes-home resolver used by the memos-local-plugin adapter. + +Mirrors Hermes' own ``_get_platform_default_hermes_home`` so the plugin's +runtime data, PID files, and native import sources land inside the same +directory the Hermes daemon reads on every platform. + +Resolution precedence: + +1. ``HERMES_HOME`` environment variable (path is expanded + resolved). +2. On win32: ``%LOCALAPPDATA%\\hermes`` (with fallback + ``~/AppData/Local/hermes`` when ``LOCALAPPDATA`` is unset). +3. On any other platform: ``~/.hermes``. + +Regression: issue #2221 — the plugin previously hard-coded ``~/.hermes`` +even on Windows, which put its data outside Hermes' real home. +""" + +from __future__ import annotations + +import os +import sys + +from pathlib import Path + + +def _expand( + value: str, + env: dict[str, str] | None = None, + platform: str | None = None, +) -> Path: + """Expand ~ using the platform-appropriate current-user home. + + Only the bare ``~``, ``~/…`` and ``~\\…`` forms are supported: the + plugin resolves the *current* user's Hermes home, so shell-style + ``~username/…`` values (which point at a different user's home) are + rejected with :class:`ValueError` rather than being silently resolved + against the current working directory. + """ + src = value + effective_env = dict(os.environ) if env is None else env + effective_platform = platform if platform is not None else sys.platform + if effective_platform == "win32": + home = effective_env.get("USERPROFILE", "").strip() + home = home or effective_env.get("HOME", "").strip() + else: + home = effective_env.get("HOME", "").strip() + home = home or effective_env.get("USERPROFILE", "").strip() + if src.startswith("~"): + base = home or str(Path.home()) + if src == "~": + src = base + elif src.startswith(("~/", "~\\")): + src = str(Path(base) / src[2:]) + else: + # e.g. "~alice/hermes" — POSIX-style named-user expansion is + # deliberately out of scope. Fail loudly so callers get a + # clear signal instead of a path resolved against CWD. + raise ValueError(f"named-user tilde paths are not supported: {value!r}") + return Path(src).resolve() + + +def resolve_hermes_home( + env: dict[str, str] | None = None, + platform: str | None = None, +) -> Path: + """Return the canonical Hermes home directory for the given env/platform. + + ``env`` and ``platform`` default to ``os.environ`` and ``sys.platform`` + respectively; callers pass them explicitly to keep the resolver + unit-testable without process mutation. + """ + effective_env = dict(os.environ) if env is None else dict(env) + effective_platform = platform if platform is not None else sys.platform + + hermes_home = effective_env.get("HERMES_HOME", "").strip() + if hermes_home: + return _expand(hermes_home, effective_env, effective_platform) + + if effective_platform == "win32": + local_appdata = effective_env.get("LOCALAPPDATA", "").strip() + if local_appdata: + return _expand(local_appdata, effective_env, effective_platform) / "hermes" + # Match Hermes' fallback: /AppData/Local/hermes. Use + # ``.strip()`` guards so an env var that is *set* to an empty + # string (common in scrubbed subprocess envs) still falls back + # to ``Path.home()`` rather than resolving CWD. + home = ( + effective_env.get("USERPROFILE", "").strip() + or effective_env.get("HOME", "").strip() + or str(Path.home()) + ) + return _expand(home, effective_env, effective_platform) / "AppData" / "Local" / "hermes" + + home = ( + effective_env.get("HOME", "").strip() + or effective_env.get("USERPROFILE", "").strip() + or str(Path.home()) + ) + return _expand(home, effective_env, effective_platform) / ".hermes" + + +__all__ = ["resolve_hermes_home"] diff --git a/apps/memos-local-plugin/bridge.cts b/apps/memos-local-plugin/bridge.cts index acc1ccb29..87ffd83ff 100644 --- a/apps/memos-local-plugin/bridge.cts +++ b/apps/memos-local-plugin/bridge.cts @@ -92,15 +92,8 @@ function parseArgs(argv: readonly string[]): BridgeArgs { const PID_FILENAME = "bridge.pid"; const STDIO_PID_FILENAME = "bridge-stdio.pid"; -function pidFilePath(agent: string, filename: string = PID_FILENAME): string { - const agentHome = agent === "hermes" ? ".hermes" : ".openclaw"; - return path.join( - process.env.HOME ?? "/tmp", - agentHome, - "memos-plugin", - "daemon", - filename, - ); +function pidFilePath(runtimeHome: string, filename: string = PID_FILENAME): string { + return path.join(runtimeHome, "daemon", filename); } function readPidFile(pidPath: string): number | null { @@ -160,13 +153,17 @@ function killExistingBridge(pidPath: string, timeoutMs = 5000): void { async function main(): Promise { const args = parseArgs(process.argv.slice(2)); + const { resolveHome } = (await importEsm( + runtimeModule("core/config/paths.ts", "dist/core/config/paths.js") + )) as typeof import("./core/config/paths.js"); + const pidRuntimeHome = resolveHome(args.agent, args.home).root; // ─── Singleton: kill previous bridge that owns the viewer port ─── - const pidPath = pidFilePath(args.agent); + const pidPath = pidFilePath(pidRuntimeHome); const stdioPidFilename = args.runtimeScope ? `bridge-stdio-${args.runtimeScope}.pid` : STDIO_PID_FILENAME; - const stdioPidPath = pidFilePath(args.agent, stdioPidFilename); + const stdioPidPath = pidFilePath(pidRuntimeHome, stdioPidFilename); const ownsViewerPort = args.daemon || !args.noViewer; const removeOwnedPidFile = () => { if (ownsViewerPort) removePidFile(pidPath); @@ -272,11 +269,6 @@ async function main(): Promise { runtimeModule("core/telemetry/index.ts", "dist/core/telemetry/index.js") )) as typeof import("./core/telemetry/index.js"); - // Resolve home early so we can use resolveHome with explicit defaultHome - const { resolveHome } = (await importEsm( - runtimeModule("core/config/paths.ts", "dist/core/config/paths.js") - )) as typeof import("./core/config/paths.js"); - const resolvedHome = args.home ? resolveHome(args.agent, args.home) : undefined; diff --git a/apps/memos-local-plugin/bridge.mts b/apps/memos-local-plugin/bridge.mts index 2d967c597..6ea9d5d65 100644 --- a/apps/memos-local-plugin/bridge.mts +++ b/apps/memos-local-plugin/bridge.mts @@ -33,6 +33,8 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; +import { resolveHome } from "./core/config/paths.js"; + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -78,15 +80,8 @@ function parseArgs(argv: readonly string[]): BridgeArgs { const PID_FILENAME = "bridge.pid"; -function pidFilePath(agent: string): string { - const agentHome = agent === "hermes" ? ".hermes" : ".openclaw"; - return path.join( - process.env.HOME ?? "/tmp", - agentHome, - "memos-plugin", - "daemon", - PID_FILENAME, - ); +function pidFilePath(runtimeHome: string): string { + return path.join(runtimeHome, "daemon", PID_FILENAME); } function readPidFile(pidPath: string): number | null { @@ -146,9 +141,10 @@ function killExistingBridge(pidPath: string, timeoutMs = 5000): void { async function main(): Promise { const args = parseArgs(process.argv.slice(2)); + const pidRuntimeHome = resolveHome(args.agent, args.home).root; // ─── Singleton: kill previous bridge that owns the viewer port ─── - const pidPath = pidFilePath(args.agent); + const pidPath = pidFilePath(pidRuntimeHome); const ownsViewerPort = args.daemon || !args.noViewer; const removeOwnedPidFile = () => { if (ownsViewerPort) removePidFile(pidPath); @@ -235,9 +231,6 @@ async function main(): Promise { const { Telemetry } = await import("./core/telemetry/index.js"); - // Resolve home early so we can use resolveHome with explicit defaultHome - const { resolveHome } = await import("./core/config/paths.js"); - const resolvedHome = args.home ? resolveHome(args.agent, args.home) : undefined; diff --git a/apps/memos-local-plugin/core/config/hermes-home.ts b/apps/memos-local-plugin/core/config/hermes-home.ts new file mode 100644 index 000000000..50e4cb3c2 --- /dev/null +++ b/apps/memos-local-plugin/core/config/hermes-home.ts @@ -0,0 +1,91 @@ +/** + * Canonical Hermes home resolver, shared by the plugin's bridge entries, + * `core/config/paths.ts`, and the server routes that reach into the + * Hermes host workspace-src-admin-dashboard for legacy/native imports. + * + * Mirrors Hermes' own `_get_platform_default_hermes_home` so the + * plugin's runtime state, PID files, and import sources land inside the + * directory the Hermes daemon actually reads: + * + * 1. `HERMES_HOME` env override (works on every platform). + * 2. On win32: `%LOCALAPPDATA%\hermes` (fallback: `/AppData/Local/hermes`). + * 3. On any other platform: `~/.hermes`. + * + * Callers should not hardcode `~/.hermes`; issue #2221 exists because + * they historically did. + */ + +import { homedir } from "node:os"; +import { posix, win32 } from "node:path"; + +/** + * Structural env type; matches `process.env` but stays usable in tests + * that build a plain object without pulling in the whole `NodeJS.ProcessEnv` + * shape. Exported so callers who construct typed mock envs can share it. + */ +export type EnvLike = Record; + +/** + * Resolve the current user's home directory using the same precedence as + * Hermes' Python resolver: on Windows prefer `USERPROFILE`, on POSIX + * prefer `HOME`, falling back to node's `homedir()` on both. Empty + * strings are treated as unset so a scrubbed env doesn't collapse the + * fallback chain. + */ +function resolveHome(env: EnvLike, platform: NodeJS.Platform): string { + const homeEnv = (env["HOME"] ?? "").trim(); + const userProfile = (env["USERPROFILE"] ?? "").trim(); + if (platform === "win32") { + if (userProfile) return userProfile; + if (homeEnv) return homeEnv; + } else { + if (homeEnv) return homeEnv; + if (userProfile) return userProfile; + } + return homedir(); +} + +/** + * Expand a leading `~` against the supplied `home`. Only the bare `~`, + * `~/…` and `~\…` forms are supported — POSIX-style `~username/…` + * expansion is deliberately out of scope, so it raises rather than + * silently resolving relative to CWD. + */ +function expandHomePath( + value: string, + home: string, + platform: NodeJS.Platform, +): string { + const path = platform === "win32" ? win32 : posix; + let out = value; + if (out === "~") { + out = home; + } else if (out.startsWith("~/") || out.startsWith("~\\")) { + out = path.join(home, out.slice(2)); + } else if (out.startsWith("~")) { + throw new Error( + `named-user tilde paths are not supported: ${JSON.stringify(value)}`, + ); + } + return path.resolve(out); +} + +export function resolveHermesHome( + env: EnvLike = process.env, + platform: NodeJS.Platform = process.platform, +): string { + const path = platform === "win32" ? win32 : posix; + const home = resolveHome(env, platform); + + const hermesHome = (env["HERMES_HOME"] ?? "").trim(); + if (hermesHome) return expandHomePath(hermesHome, home, platform); + + if (platform === "win32") { + const localAppData = (env["LOCALAPPDATA"] ?? "").trim(); + if (localAppData) return path.resolve(path.join(localAppData, "hermes")); + // Match Hermes' own fallback when LOCALAPPDATA is unset. + return path.resolve(path.join(home, "AppData", "Local", "hermes")); + } + + return path.resolve(path.join(home, ".hermes")); +} diff --git a/apps/memos-local-plugin/core/config/paths.ts b/apps/memos-local-plugin/core/config/paths.ts index 66f8553ea..dd870ec69 100644 --- a/apps/memos-local-plugin/core/config/paths.ts +++ b/apps/memos-local-plugin/core/config/paths.ts @@ -10,6 +10,7 @@ import { homedir } from "node:os"; import { resolve as pathResolve, join } from "node:path"; import type { AgentKind } from "../types.js"; +import { resolveHermesHome } from "./hermes-home.js"; export interface ResolvedHome { /** Absolute path to the runtime root (e.g. ~/.openclaw/memos-plugin). */ @@ -29,7 +30,8 @@ export interface ResolvedHome { const DEFAULT_HOME_BY_AGENT: Record = { openclaw: "{HOME}/.openclaw/memos-plugin", - hermes: "{HOME}/.hermes/memos-plugin", + // `hermes` is resolved dynamically via `resolveHermesHome()` so it + // honours `HERMES_HOME` and platform conventions (issue #2221). }; /** @@ -39,7 +41,9 @@ const DEFAULT_HOME_BY_AGENT: Record = { * 2. `MEMOS_CONFIG_FILE` environment variable (covers only the config file * path; data/skills/logs still derive from the same parent dir). * 3. `defaultHome` argument. - * 4. Built-in default for `agent` (`~/.openclaw/memos-plugin/` etc.). + * 4. Built-in default for `agent`: + * - `hermes` → `resolveHermesHome() + /memos-plugin` + * - other → `~/./memos-plugin` */ export function resolveHome(agent: AgentKind, defaultHome?: string): ResolvedHome { const env = process.env; @@ -58,8 +62,11 @@ export function resolveHome(agent: AgentKind, defaultHome?: string): ResolvedHom } else if (defaultHome && defaultHome.trim()) { root = pathResolve(expandHome(defaultHome)); configFile = join(root, "config.yaml"); + } else if (agent === "hermes") { + root = pathResolve(join(resolveHermesHome(), "memos-plugin")); + configFile = join(root, "config.yaml"); } else { - const tmpl = DEFAULT_HOME_BY_AGENT[String(agent)] ?? `{HOME}/.${agent}/memos-plugin`; + const tmpl = DEFAULT_HOME_BY_AGENT[agent] ?? `{HOME}/.${agent}/memos-plugin`; root = pathResolve(expandHome(tmpl)); configFile = join(root, "config.yaml"); } diff --git a/apps/memos-local-plugin/server/routes/import-export.ts b/apps/memos-local-plugin/server/routes/import-export.ts index efdf16595..5d52ac4b5 100644 --- a/apps/memos-local-plugin/server/routes/import-export.ts +++ b/apps/memos-local-plugin/server/routes/import-export.ts @@ -30,6 +30,7 @@ import { join } from "node:path"; import type { TraceDTO } from "../../agent-contract/dto.js"; import type { ServerOptions } from "../types.js"; import type { ServerDeps } from "../types.js"; +import { resolveHermesHome } from "../../core/config/hermes-home.js"; import { parseJson, writeError, type Routes } from "./registry.js"; import { writeJson } from "../middleware/io.js"; @@ -334,7 +335,10 @@ function parseMultipartBundle(contentType: string, body: Buffer): string | null } function hermesNativeMemoryPath(): string { - return join(homedir(), ".hermes", "memories", "MEMORY.md"); + // Anchor on the canonical Hermes home so Windows finds MEMORY.md under + // %LOCALAPPDATA%\hermes\memories instead of %USERPROFILE%\.hermes\memories + // (issue #2221). + return join(resolveHermesHome(), "memories", "MEMORY.md"); } function openClawHome(): string { diff --git a/apps/memos-local-plugin/server/routes/migrate.ts b/apps/memos-local-plugin/server/routes/migrate.ts index ce5679812..f43302805 100644 --- a/apps/memos-local-plugin/server/routes/migrate.ts +++ b/apps/memos-local-plugin/server/routes/migrate.ts @@ -49,6 +49,7 @@ import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { rootLogger } from "../../core/logger/index.js"; +import { resolveHermesHome } from "../../core/config/hermes-home.js"; import type { ServerDeps, ServerOptions } from "../types.js"; import { writeError, type Routes, type RouteContext } from "./registry.js"; import type { TraceDTO, SkillDTO } from "../../agent-contract/dto.js"; @@ -67,7 +68,10 @@ const log = rootLogger.child({ channel: "server.migrate" }); function legacyDbPath(agent: LegacyAgent): string { switch (agent) { case "hermes": - return join(homedir(), ".hermes", "memos-state", "memos-local", "memos.db"); + // Anchor on the canonical Hermes home (HERMES_HOME → + // %LOCALAPPDATA%\hermes on Windows → ~/.hermes on POSIX) so the + // migration finds the real legacy DB on every platform (#2221). + return join(resolveHermesHome(), "memos-state", "memos-local", "memos.db"); case "openclaw": default: return join(homedir(), ".openclaw", "memos-local", "memos.db"); diff --git a/apps/memos-local-plugin/tests/python/test_hermes_home.py b/apps/memos-local-plugin/tests/python/test_hermes_home.py new file mode 100644 index 000000000..4e2726b56 --- /dev/null +++ b/apps/memos-local-plugin/tests/python/test_hermes_home.py @@ -0,0 +1,157 @@ +"""Unit tests for the shared Hermes home resolver. + +Regression: issue #2221 — the memos-local-plugin hard-coded ~/.hermes on +Windows, while Hermes itself uses %LOCALAPPDATA%\\hermes ($HERMES_HOME). +The plugin's runtime state, PID files, and native import sources +therefore landed outside Hermes' real home. + +These tests pin the four resolution branches Hermes exposes: + + 1. HERMES_HOME env override wins on every platform. + 2. On win32 with LOCALAPPDATA set → /hermes. + 3. On win32 without LOCALAPPDATA → /AppData/Local/hermes. + 4. On any other platform → ~/.hermes. + +The helper also snapshots the child-process environment (so it stays +consistent with the JSON-RPC bridge subprocess). +""" + +from __future__ import annotations + +import os +import sys +import unittest + +from pathlib import Path + + +_ADAPTER_ROOT = Path(__file__).resolve().parent.parent.parent / "adapters" / "hermes" +_PLUGIN_DIR = _ADAPTER_ROOT / "memos_provider" +for _p in (_ADAPTER_ROOT, _PLUGIN_DIR): + if str(_p) not in sys.path: + sys.path.insert(0, str(_p)) + + +class HermesHomeResolverTests(unittest.TestCase): + """Contract tests for `resolve_hermes_home()`.""" + + def test_hermes_home_env_wins_on_posix(self) -> None: + from hermes_home import resolve_hermes_home + + env = { + "HERMES_HOME": "/tmp/custom-hermes-home", + "LOCALAPPDATA": "C:\\Users\\bob\\AppData\\Local", + } + got = resolve_hermes_home(env=env, platform="linux") + self.assertEqual(got, Path("/tmp/custom-hermes-home").resolve()) + + def test_hermes_home_env_wins_on_windows(self) -> None: + from hermes_home import resolve_hermes_home + + env = { + "HERMES_HOME": "/tmp/hermes-workshop", + "LOCALAPPDATA": "/tmp/localappdata", + } + got = resolve_hermes_home(env=env, platform="win32") + self.assertEqual(got, Path("/tmp/hermes-workshop").resolve()) + + def test_windows_uses_localappdata_when_hermes_home_unset(self) -> None: + from hermes_home import resolve_hermes_home + + env = {"LOCALAPPDATA": "/tmp/localappdata"} + got = resolve_hermes_home(env=env, platform="win32") + self.assertEqual( + got, + Path("/tmp/localappdata/hermes").resolve(), + ) + + def test_windows_falls_back_to_home_appdata_when_localappdata_missing(self) -> None: + from hermes_home import resolve_hermes_home + + env = { + "USERPROFILE": "/home/windows-user", + "HOME": "/home/posix-user", + } + got = resolve_hermes_home(env=env, platform="win32") + self.assertEqual( + got, + Path("/home/windows-user/AppData/Local/hermes").resolve(), + ) + + def test_windows_tilde_override_prefers_userprofile(self) -> None: + from hermes_home import resolve_hermes_home + + env = { + "HERMES_HOME": "~/custom-hermes", + "USERPROFILE": "/home/windows-user", + "HOME": "/home/posix-user", + } + got = resolve_hermes_home(env=env, platform="win32") + self.assertEqual(got, Path("/home/windows-user/custom-hermes").resolve()) + + def test_posix_default_is_dot_hermes(self) -> None: + from hermes_home import resolve_hermes_home + + env = {"HOME": "/home/alice"} + got = resolve_hermes_home(env=env, platform="linux") + self.assertEqual(got, Path("/home/alice/.hermes").resolve()) + + def test_default_uses_process_env_and_platform_when_none(self) -> None: + """When no args are provided the helper must read os.environ / sys.platform.""" + from hermes_home import resolve_hermes_home + + got = resolve_hermes_home() + env_home = os.environ.get("HERMES_HOME", "").strip() + if env_home: + self.assertEqual(str(got), str(Path(env_home).expanduser().resolve())) + else: + # Any platform is fine; the branch must not raise and must + # return an absolute path. + self.assertTrue(got.is_absolute(), f"expected absolute path, got {got!r}") + + +class ResolvedRuntimeHomeUsesResolverTests(unittest.TestCase): + """The Python bridge/adapter fallbacks must go through the resolver.""" + + def test_memos_provider_fallback_uses_hermes_home_env(self) -> None: + import memos_provider + + original = os.environ.get("HERMES_HOME") + original_memos = os.environ.get("MEMOS_HOME") + original_config = os.environ.get("MEMOS_CONFIG_FILE") + try: + os.environ["HERMES_HOME"] = "/tmp/regression-2221-hermes" + os.environ.pop("MEMOS_HOME", None) + os.environ.pop("MEMOS_CONFIG_FILE", None) + + got = memos_provider._resolved_memos_runtime_home() + self.assertEqual( + got, + Path("/tmp/regression-2221-hermes/memos-plugin").resolve(), + ) + finally: + if original is None: + os.environ.pop("HERMES_HOME", None) + else: + os.environ["HERMES_HOME"] = original + if original_memos is not None: + os.environ["MEMOS_HOME"] = original_memos + if original_config is not None: + os.environ["MEMOS_CONFIG_FILE"] = original_config + + def test_bridge_client_fallback_uses_hermes_home_env(self) -> None: + import bridge_client + + env = { + "HERMES_HOME": "/tmp/regression-2221-bridge", + "HOME": "/home/tester", + } + got = bridge_client._resolved_runtime_home("hermes", env) + self.assertEqual( + got, + Path("/tmp/regression-2221-bridge/memos-plugin").resolve(), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/memos-local-plugin/tests/unit/config/hermes-home.test.ts b/apps/memos-local-plugin/tests/unit/config/hermes-home.test.ts new file mode 100644 index 000000000..ce0bffda3 --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/config/hermes-home.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { posix, win32 } from "node:path"; + +import { resolveHermesHome } from "../../../core/config/hermes-home.js"; + +/** + * Regression: issue #2221 — the plugin's default Hermes home was + * `~/.hermes` on every platform. On Windows, Hermes itself uses + * `%LOCALAPPDATA%\hermes` (HERMES_HOME). These tests pin the four + * branches of the shared resolver so both bridge.cts and bridge.mts, + * plus the various server routes and paths.ts, share one convention. + */ +describe("config/hermes-home", () => { + it("HERMES_HOME env overrides everything on POSIX", () => { + const home = resolveHermesHome( + { HERMES_HOME: "/tmp/custom-hermes", HOME: "/home/alice" }, + "linux", + ); + expect(home).toBe(posix.resolve("/tmp/custom-hermes")); + }); + + it("HERMES_HOME env overrides everything on Windows", () => { + const home = resolveHermesHome( + { + HERMES_HOME: "D:\\hermes-workshop", + LOCALAPPDATA: "C:\\Users\\bob\\AppData\\Local", + }, + "win32", + ); + expect(home).toBe(win32.resolve("D:\\hermes-workshop")); + }); + + it("Windows uses LOCALAPPDATA/hermes when HERMES_HOME is unset", () => { + const home = resolveHermesHome( + { LOCALAPPDATA: "C:\\Users\\bob\\AppData\\Local" }, + "win32", + ); + expect(home).toBe(win32.resolve("C:\\Users\\bob\\AppData\\Local\\hermes")); + }); + + it("Windows falls back to /AppData/Local/hermes when LOCALAPPDATA is missing", () => { + const home = resolveHermesHome( + { USERPROFILE: "C:\\Users\\bob", HOME: "D:\\fallback" }, + "win32", + ); + expect(home).toBe(win32.resolve("C:\\Users\\bob\\AppData\\Local\\hermes")); + }); + + it("Windows expands a tilde override against USERPROFILE", () => { + const home = resolveHermesHome( + { + HERMES_HOME: "~/hermes-workshop", + USERPROFILE: "C:\\Users\\bob", + HOME: "D:\\fallback", + }, + "win32", + ); + expect(home).toBe(win32.resolve("C:\\Users\\bob\\hermes-workshop")); + }); + + it("POSIX default is ~/.hermes", () => { + const home = resolveHermesHome({ HOME: "/home/alice" }, "linux"); + expect(home).toBe(posix.resolve("/home/alice/.hermes")); + }); + + it("POSIX default on darwin honours HOME", () => { + const home = resolveHermesHome({ HOME: "/Users/carol" }, "darwin"); + expect(home).toBe(posix.resolve("/Users/carol/.hermes")); + }); +}); diff --git a/apps/memos-local-plugin/tests/unit/config/paths.test.ts b/apps/memos-local-plugin/tests/unit/config/paths.test.ts index 98622d2fb..75850bd87 100644 --- a/apps/memos-local-plugin/tests/unit/config/paths.test.ts +++ b/apps/memos-local-plugin/tests/unit/config/paths.test.ts @@ -51,4 +51,14 @@ describe("config/paths", () => { const home = resolveHome("custom"); expect(home.root.endsWith(".custom/memos-plugin")).toBe(true); }); + + it("resolveHome(hermes) honours HERMES_HOME when MEMOS_* are unset (#2221)", () => { + delete process.env["MEMOS_HOME"]; + delete process.env["MEMOS_CONFIG_FILE"]; + process.env["HERMES_HOME"] = "/tmp/regression-2221-paths"; + const home = resolveHome("hermes"); + // The plugin's runtime home nests inside the Hermes home. + expect(home.root.endsWith("regression-2221-paths/memos-plugin")).toBe(true); + expect(home.configFile).toBe(join(home.root, "config.yaml")); + }); });