From 4cf0a3128607470d40c86b571d6b0f16f9e0e56e Mon Sep 17 00:00:00 2001 From: autodev Date: Wed, 5 Aug 2026 17:02:05 +0800 Subject: [PATCH 1/3] fix(plugin): honour HERMES_HOME + %LOCALAPPDATA% on Windows (#2221) The memos-local-plugin resolved the Hermes home as ~/.hermes in several places, while Hermes itself uses %LOCALAPPDATA%\hermes on Windows (HERMES_HOME). The plugin's runtime data, PID files, and native import sources therefore landed outside Hermes' real home on Windows: install config never reached the daemon (#2211), native memory import missed MEMORY.md (#2210), hermes backup could skip plugin state, and host and plugin tooling disagreed on where the data lived. Add a single canonical Hermes-home resolver on each language side that mirrors Hermes' own _get_platform_default_hermes_home: - Python: adapters/hermes/memos_provider/hermes_home.py - TypeScript: core/config/hermes-home.ts Resolution: HERMES_HOME env -> %LOCALAPPDATA%\hermes on win32 (with ~/AppData/Local/hermes fallback) -> ~/.hermes elsewhere. All hard-coded sites now route through it: the Python provider fallback + child-session lookup, the bridge_client runtime home, both bridge.cts/bridge.mts pidFilePath resolvers, core/config/paths.ts resolveHome (hermes default), and the migrate + import-export server routes. Non-Hermes agents (openclaw, custom) keep the ~/./memos-plugin convention. MEMOS_HOME / MEMOS_CONFIG_FILE still win over HERMES_HOME. Tests: added tests/python/test_hermes_home.py (8 tests) and tests/unit/config/hermes-home.test.ts (6 tests) covering all four resolver branches plus the Python provider / bridge_client integration paths. Extended tests/unit/config/paths.test.ts with a resolveHome("hermes") + HERMES_HOME regression assertion. Full Python suite (109) and full vitest suite (1274) pass; tsc --noEmit clean. --- .../hermes/memos_provider/__init__.py | 7 +- .../hermes/memos_provider/bridge_client.py | 15 +- .../hermes/memos_provider/hermes_home.py | 82 +++++++++ apps/memos-local-plugin/bridge.cts | 36 +++- apps/memos-local-plugin/bridge.mts | 15 +- .../core/config/hermes-home.ts | 58 +++++++ apps/memos-local-plugin/core/config/paths.ts | 11 +- .../server/routes/import-export.ts | 6 +- .../server/routes/migrate.ts | 6 +- .../tests/python/test_hermes_home.py | 155 ++++++++++++++++++ .../tests/unit/config/hermes-home.test.ts | 64 ++++++++ .../tests/unit/config/paths.test.ts | 10 ++ 12 files changed, 453 insertions(+), 12 deletions(-) create mode 100644 apps/memos-local-plugin/adapters/hermes/memos_provider/hermes_home.py create mode 100644 apps/memos-local-plugin/core/config/hermes-home.ts create mode 100644 apps/memos-local-plugin/tests/python/test_hermes_home.py create mode 100644 apps/memos-local-plugin/tests/unit/config/hermes-home.test.ts 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 6eeaa000e..ef85a48c8 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]: @@ -1224,7 +1227,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 6863ba1ef..c09bf2a80 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 @@ -24,6 +24,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 @@ -64,8 +66,17 @@ 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..123a7d37c --- /dev/null +++ b/apps/memos-local-plugin/adapters/hermes/memos_provider/hermes_home.py @@ -0,0 +1,82 @@ +"""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) -> Path: + """Expand ~ using the caller-supplied HOME if available. + + Uses ``os.path.expanduser`` for the leading ``~`` semantics but + swaps in the child-environment HOME first so bridge subprocesses + inherit a stable resolution. + """ + src = value + home = "" + if env is not None: + home = env.get("HOME", "").strip() or env.get("USERPROFILE", "").strip() + if src.startswith("~"): + base = home or str(Path.home()) + if src == "~": + src = base + elif src.startswith("~/") or src.startswith("~\\"): + src = str(Path(base) / src[2:]) + 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[str, str] + if env is None: + effective_env = dict(os.environ) + else: + effective_env = 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) + + if effective_platform == "win32": + local_appdata = effective_env.get("LOCALAPPDATA", "").strip() + if local_appdata: + return _expand(local_appdata, effective_env) / "hermes" + # Match Hermes' fallback: /AppData/Local/hermes. + home = effective_env.get( + "USERPROFILE", + effective_env.get("HOME", "") or str(Path.home()), + ) + return _expand(home, effective_env) / "AppData" / "Local" / "hermes" + + home = effective_env.get("HOME", "").strip() or str(Path.home()) + return _expand(home, effective_env) / ".hermes" + + +__all__ = ["resolve_hermes_home"] diff --git a/apps/memos-local-plugin/bridge.cts b/apps/memos-local-plugin/bridge.cts index acc1ccb29..e60eb733b 100644 --- a/apps/memos-local-plugin/bridge.cts +++ b/apps/memos-local-plugin/bridge.cts @@ -93,16 +93,48 @@ 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"; + if (agent === "hermes") { + // Honour HERMES_HOME / %LOCALAPPDATA%\hermes so the PID file lives + // inside the same Hermes home the daemon reads (issue #2221). + return path.join(resolveHermesHomeSync(), "memos-plugin", "daemon", filename); + } return path.join( process.env.HOME ?? "/tmp", - agentHome, + `.${agent}`, "memos-plugin", "daemon", filename, ); } +// `main()` is async and the ESM `resolveHermesHome` import happens lazily +// inside it. `pidFilePath` runs *before* that dynamic import completes, +// so we mirror the same tiny resolver here to avoid loading ESM twice. +function resolveHermesHomeSync(): string { + const env = process.env; + const explicit = (env["HERMES_HOME"] ?? "").trim(); + if (explicit) return path.resolve(expandUserSync(explicit)); + + if (process.platform === "win32") { + const local = (env["LOCALAPPDATA"] ?? "").trim(); + if (local) return path.resolve(path.join(expandUserSync(local), "hermes")); + const home = env["USERPROFILE"] || env["HOME"] || "/tmp"; + return path.resolve(path.join(expandUserSync(home), "AppData", "Local", "hermes")); + } + const home = env["HOME"] || "/tmp"; + return path.resolve(path.join(expandUserSync(home), ".hermes")); +} + +function expandUserSync(value: string): string { + const env = process.env; + const home = env["HOME"] || env["USERPROFILE"] || ""; + if (value === "~") return home || value; + if (value.startsWith("~/") || value.startsWith("~\\")) { + return path.join(home || "", value.slice(2)); + } + return value; +} + function readPidFile(pidPath: string): number | null { try { const raw = fs.readFileSync(pidPath, "utf8").trim(); diff --git a/apps/memos-local-plugin/bridge.mts b/apps/memos-local-plugin/bridge.mts index 2d967c597..0b248c7e2 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 { resolveHermesHome } from "./core/config/hermes-home.js"; + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -79,10 +81,19 @@ function parseArgs(argv: readonly string[]): BridgeArgs { const PID_FILENAME = "bridge.pid"; function pidFilePath(agent: string): string { - const agentHome = agent === "hermes" ? ".hermes" : ".openclaw"; + if (agent === "hermes") { + // Honour HERMES_HOME / %LOCALAPPDATA%\hermes so the PID file lives + // inside the same Hermes home the daemon reads (issue #2221). + return path.join( + resolveHermesHome(), + "memos-plugin", + "daemon", + PID_FILENAME, + ); + } return path.join( process.env.HOME ?? "/tmp", - agentHome, + `.${agent}`, "memos-plugin", "daemon", PID_FILENAME, 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..d9ee5ebe1 --- /dev/null +++ b/apps/memos-local-plugin/core/config/hermes-home.ts @@ -0,0 +1,58 @@ +/** + * 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 { resolve as pathResolve, join } from "node:path"; + +type EnvLike = NodeJS.ProcessEnv | Record; + +function expandHomePath(value: string, env: EnvLike): string { + let out = value; + const home = + (typeof env["HOME"] === "string" && env["HOME"]) || + (typeof env["USERPROFILE"] === "string" && env["USERPROFILE"]) || + homedir(); + if (out === "~") out = home; + else if (out.startsWith("~/") || out.startsWith("~\\")) { + out = join(home, out.slice(2)); + } + return pathResolve(out); +} + +export function resolveHermesHome( + env: EnvLike = process.env, + platform: NodeJS.Platform = process.platform, +): string { + const hermesHome = (env["HERMES_HOME"] ?? "").trim(); + if (hermesHome) return expandHomePath(hermesHome, env); + + if (platform === "win32") { + const localAppData = (env["LOCALAPPDATA"] ?? "").trim(); + if (localAppData) return pathResolve(join(expandHomePath(localAppData, env), "hermes")); + // Match Hermes' own fallback when LOCALAPPDATA is unset. + const home = + (typeof env["USERPROFILE"] === "string" && env["USERPROFILE"]) || + (typeof env["HOME"] === "string" && env["HOME"]) || + homedir(); + return pathResolve(join(expandHomePath(home, env), "AppData", "Local", "hermes")); + } + + const home = + (typeof env["HOME"] === "string" && env["HOME"]) || + homedir(); + return pathResolve(join(expandHomePath(home, env), ".hermes")); +} diff --git a/apps/memos-local-plugin/core/config/paths.ts b/apps/memos-local-plugin/core/config/paths.ts index 66f8553ea..ad51a3745 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,6 +62,9 @@ export function resolveHome(agent: AgentKind, defaultHome?: string): ResolvedHom } else if (defaultHome && defaultHome.trim()) { root = pathResolve(expandHome(defaultHome)); configFile = join(root, "config.yaml"); + } else if (String(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`; root = pathResolve(expandHome(tmpl)); 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..36ec6ea70 --- /dev/null +++ b/apps/memos-local-plugin/tests/python/test_hermes_home.py @@ -0,0 +1,155 @@ +"""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(str(got), "/tmp/custom-hermes-home") + + def test_hermes_home_env_wins_on_windows(self) -> None: + from hermes_home import resolve_hermes_home + + env = { + "HERMES_HOME": "D:\\hermes-workshop", + "LOCALAPPDATA": "C:\\Users\\bob\\AppData\\Local", + } + got = resolve_hermes_home(env=env, platform="win32") + # Path resolution should preserve the drive letter path. + self.assertIn("hermes-workshop", str(got)) + + def test_windows_uses_localappdata_when_hermes_home_unset(self) -> None: + from hermes_home import resolve_hermes_home + + env = {"LOCALAPPDATA": "C:\\Users\\bob\\AppData\\Local"} + got = resolve_hermes_home(env=env, platform="win32") + got_str = str(got).replace("/", "\\") + # The last two segments must always be AppData\Local\hermes. + self.assertTrue( + got_str.endswith("AppData\\Local\\hermes") + or got_str.endswith("AppData\\Local\\hermes\\"), + f"expected LOCALAPPDATA/hermes, got {got_str!r}", + ) + + def test_windows_falls_back_to_home_appdata_when_localappdata_missing(self) -> None: + from hermes_home import resolve_hermes_home + + env = {"HOME": "C:\\Users\\bob"} + got = resolve_hermes_home(env=env, platform="win32") + got_str = str(got).replace("/", "\\") + # Falls back to ~/AppData/Local/hermes on Windows. + self.assertIn("AppData\\Local\\hermes", got_str) + + 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") + # Path.resolve() normalizes; check by suffix. + self.assertTrue( + str(got).endswith("/.hermes") or str(got).endswith("\\.hermes"), + f"expected ~/.hermes, got {got!r}", + ) + + 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() or str(got)) + + +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() + got_str = str(got) + # /tmp/regression-2221-hermes/memos-plugin + self.assertTrue( + got_str.endswith("/memos-plugin") or got_str.endswith("\\memos-plugin"), + f"expected …/memos-plugin, got {got_str!r}", + ) + self.assertIn("regression-2221-hermes", got_str) + 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) + got_str = str(got) + self.assertIn("regression-2221-bridge", got_str) + self.assertTrue( + got_str.endswith("/memos-plugin") or got_str.endswith("\\memos-plugin"), + f"expected …/memos-plugin, got {got_str!r}", + ) + + +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..1fbdded55 --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/config/hermes-home.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { join } 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", + ); + // Path.resolve normalises; ensure the override is preserved. + expect(home.endsWith("custom-hermes")).toBe(true); + }); + + 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.includes("hermes-workshop")).toBe(true); + }); + + it("Windows uses LOCALAPPDATA/hermes when HERMES_HOME is unset", () => { + const home = resolveHermesHome( + { LOCALAPPDATA: "C:\\Users\\bob\\AppData\\Local" }, + "win32", + ); + // Node's path.resolve on POSIX won't touch drive letters, but the + // final segment must always be "hermes" and the preceding segment + // "Local". + const normalized = home.replace(/\\/g, "/"); + expect(normalized.endsWith("/hermes")).toBe(true); + expect(normalized.includes("AppData/Local/hermes")).toBe(true); + }); + + it("Windows falls back to /AppData/Local/hermes when LOCALAPPDATA is missing", () => { + const home = resolveHermesHome({ HOME: "/mnt/user" }, "win32"); + const normalized = home.replace(/\\/g, "/"); + expect(normalized.endsWith("AppData/Local/hermes")).toBe(true); + }); + + it("POSIX default is ~/.hermes", () => { + const home = resolveHermesHome({ HOME: "/home/alice" }, "linux"); + const normalized = home.replace(/\\/g, "/"); + expect(normalized).toBe(join("/home/alice", ".hermes")); + }); + + it("POSIX default on darwin honours HOME", () => { + const home = resolveHermesHome({ HOME: "/Users/carol" }, "darwin"); + const normalized = home.replace(/\\/g, "/"); + expect(normalized).toBe(join("/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")); + }); }); From baba7984d81b9c05e9c7516d08f16e265f985c82 Mon Sep 17 00:00:00 2001 From: autodev Date: Wed, 5 Aug 2026 17:40:27 +0800 Subject: [PATCH 2/3] fix(plugin): tighten Hermes home resolver against OCR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the 10 findings raised by Open Code Review on PR #2224: 1. Python `_expand` now raises `ValueError` on `~username/...` paths instead of silently resolving them against CWD. 2. Windows fallback branch strips empty `USERPROFILE`/`HOME` values before falling back to `Path.home()`, matching the guard used in the POSIX branch. 3. TypeScript resolver no longer routes `LOCALAPPDATA` through `expandHomePath` — it is already an absolute path. 4. Home directory is computed once at the top of `resolveHermesHome` and shared between the win32 and POSIX branches; the fallback priority is now platform-aware (Windows: USERPROFILE → HOME, POSIX: HOME → USERPROFILE) and matches the Python side. 5. `EnvLike` is now `Record` and is exported so external test callers can share the alias. 6. TS `expandHomePath` throws for `~username/...` values, mirroring the Python change. 7. `String(agent) === "hermes"` is now a plain `agent === "hermes"` strict comparison, since `AgentKind` is already `string`. 8. `test_default_uses_process_env_and_platform_when_none` asserts `got.is_absolute()` directly; the `or str(got)` arm made the assertion trivially true. 9. `test_windows_falls_back_to_home_appdata_when_localappdata_missing` now uses a POSIX-shaped HOME and checks both suffix and prefix so the test is portable and actually pins the fallback behaviour. 10. `test_hermes_home_env_wins_on_windows` compares the resolved path to `Path("D:\\hermes-workshop").resolve()` exactly, so a resolver that accidentally appended a suffix would fail. Tests: `python3 -m unittest discover -s tests/python` (109/109 pass), `npx vitest run` (1274/1274 pass, 2 skipped), `tsc -p tsconfig.json --noEmit` clean. --- .../hermes/memos_provider/hermes_home.py | 27 ++++++-- .../core/config/hermes-home.ts | 65 +++++++++++++------ apps/memos-local-plugin/core/config/paths.ts | 2 +- .../tests/python/test_hermes_home.py | 26 ++++++-- 4 files changed, 86 insertions(+), 34 deletions(-) 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 index 123a7d37c..f13277a38 100644 --- a/apps/memos-local-plugin/adapters/hermes/memos_provider/hermes_home.py +++ b/apps/memos-local-plugin/adapters/hermes/memos_provider/hermes_home.py @@ -26,9 +26,11 @@ def _expand(value: str, env: dict[str, str] | None = None) -> Path: """Expand ~ using the caller-supplied HOME if available. - Uses ``os.path.expanduser`` for the leading ``~`` semantics but - swaps in the child-environment HOME first so bridge subprocesses - inherit a stable resolution. + 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 home = "" @@ -40,6 +42,13 @@ def _expand(value: str, env: dict[str, str] | None = None) -> Path: src = base elif src.startswith("~/") or 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() @@ -68,10 +77,14 @@ def resolve_hermes_home( local_appdata = effective_env.get("LOCALAPPDATA", "").strip() if local_appdata: return _expand(local_appdata, effective_env) / "hermes" - # Match Hermes' fallback: /AppData/Local/hermes. - home = effective_env.get( - "USERPROFILE", - effective_env.get("HOME", "") or str(Path.home()), + # 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) / "AppData" / "Local" / "hermes" diff --git a/apps/memos-local-plugin/core/config/hermes-home.ts b/apps/memos-local-plugin/core/config/hermes-home.ts index d9ee5ebe1..1ec7653ef 100644 --- a/apps/memos-local-plugin/core/config/hermes-home.ts +++ b/apps/memos-local-plugin/core/config/hermes-home.ts @@ -18,17 +18,49 @@ import { homedir } from "node:os"; import { resolve as pathResolve, join } from "node:path"; -type EnvLike = NodeJS.ProcessEnv | Record; +/** + * 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; -function expandHomePath(value: string, env: EnvLike): string { +/** + * 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): string { let out = value; - const home = - (typeof env["HOME"] === "string" && env["HOME"]) || - (typeof env["USERPROFILE"] === "string" && env["USERPROFILE"]) || - homedir(); - if (out === "~") out = home; - else if (out.startsWith("~/") || out.startsWith("~\\")) { + if (out === "~") { + out = home; + } else if (out.startsWith("~/") || out.startsWith("~\\")) { out = join(home, out.slice(2)); + } else if (out.startsWith("~")) { + throw new Error( + `named-user tilde paths are not supported: ${JSON.stringify(value)}`, + ); } return pathResolve(out); } @@ -37,22 +69,17 @@ export function resolveHermesHome( env: EnvLike = process.env, platform: NodeJS.Platform = process.platform, ): string { + const home = resolveHome(env, platform); + const hermesHome = (env["HERMES_HOME"] ?? "").trim(); - if (hermesHome) return expandHomePath(hermesHome, env); + if (hermesHome) return expandHomePath(hermesHome, home); if (platform === "win32") { const localAppData = (env["LOCALAPPDATA"] ?? "").trim(); - if (localAppData) return pathResolve(join(expandHomePath(localAppData, env), "hermes")); + if (localAppData) return pathResolve(join(localAppData, "hermes")); // Match Hermes' own fallback when LOCALAPPDATA is unset. - const home = - (typeof env["USERPROFILE"] === "string" && env["USERPROFILE"]) || - (typeof env["HOME"] === "string" && env["HOME"]) || - homedir(); - return pathResolve(join(expandHomePath(home, env), "AppData", "Local", "hermes")); + return pathResolve(join(home, "AppData", "Local", "hermes")); } - const home = - (typeof env["HOME"] === "string" && env["HOME"]) || - homedir(); - return pathResolve(join(expandHomePath(home, env), ".hermes")); + return pathResolve(join(home, ".hermes")); } diff --git a/apps/memos-local-plugin/core/config/paths.ts b/apps/memos-local-plugin/core/config/paths.ts index ad51a3745..4cd6e4624 100644 --- a/apps/memos-local-plugin/core/config/paths.ts +++ b/apps/memos-local-plugin/core/config/paths.ts @@ -62,7 +62,7 @@ export function resolveHome(agent: AgentKind, defaultHome?: string): ResolvedHom } else if (defaultHome && defaultHome.trim()) { root = pathResolve(expandHome(defaultHome)); configFile = join(root, "config.yaml"); - } else if (String(agent) === "hermes") { + } else if (agent === "hermes") { root = pathResolve(join(resolveHermesHome(), "memos-plugin")); configFile = join(root, "config.yaml"); } else { diff --git a/apps/memos-local-plugin/tests/python/test_hermes_home.py b/apps/memos-local-plugin/tests/python/test_hermes_home.py index 36ec6ea70..f7ad0593b 100644 --- a/apps/memos-local-plugin/tests/python/test_hermes_home.py +++ b/apps/memos-local-plugin/tests/python/test_hermes_home.py @@ -53,8 +53,10 @@ def test_hermes_home_env_wins_on_windows(self) -> None: "LOCALAPPDATA": "C:\\Users\\bob\\AppData\\Local", } got = resolve_hermes_home(env=env, platform="win32") - # Path resolution should preserve the drive letter path. - self.assertIn("hermes-workshop", str(got)) + # HERMES_HOME override must be returned as-is (resolved), with no + # suffix added. Comparing to Path(...).resolve() keeps the test + # portable — both sides go through the same normalisation. + self.assertEqual(str(got), str(Path("D:\\hermes-workshop").resolve())) def test_windows_uses_localappdata_when_hermes_home_unset(self) -> None: from hermes_home import resolve_hermes_home @@ -72,11 +74,21 @@ def test_windows_uses_localappdata_when_hermes_home_unset(self) -> None: def test_windows_falls_back_to_home_appdata_when_localappdata_missing(self) -> None: from hermes_home import resolve_hermes_home - env = {"HOME": "C:\\Users\\bob"} + # Use a POSIX-shaped HOME so the test is portable — Path("C:\\…") + # is a relative-filename-with-backslash on Linux/macOS, which + # would resolve against CWD and give misleading results. + env = {"HOME": "/home/tester"} got = resolve_hermes_home(env=env, platform="win32") - got_str = str(got).replace("/", "\\") - # Falls back to ~/AppData/Local/hermes on Windows. - self.assertIn("AppData\\Local\\hermes", got_str) + got_str = str(got).replace("\\", "/") + # Falls back to /AppData/Local/hermes on Windows. + self.assertTrue( + got_str.endswith("/AppData/Local/hermes"), + f"expected …/AppData/Local/hermes, got {got_str!r}", + ) + self.assertTrue( + got_str.startswith("/home/tester/"), + f"expected HOME-rooted path, got {got_str!r}", + ) def test_posix_default_is_dot_hermes(self) -> None: from hermes_home import resolve_hermes_home @@ -100,7 +112,7 @@ def test_default_uses_process_env_and_platform_when_none(self) -> None: else: # Any platform is fine; the branch must not raise and must # return an absolute path. - self.assertTrue(got.is_absolute() or str(got)) + self.assertTrue(got.is_absolute(), f"expected absolute path, got {got!r}") class ResolvedRuntimeHomeUsesResolverTests(unittest.TestCase): From 4c7b3a124b9f3eca632cc7e8a6779f045819931c Mon Sep 17 00:00:00 2001 From: jiachengzhen Date: Fri, 7 Aug 2026 01:35:01 +0800 Subject: [PATCH 3/3] fix(plugin): harden Hermes home resolution --- .../hermes/memos_provider/bridge_client.py | 4 +- .../hermes/memos_provider/hermes_home.py | 47 +++++++----- apps/memos-local-plugin/bridge.cts | 56 ++------------ apps/memos-local-plugin/bridge.mts | 28 ++----- .../core/config/hermes-home.ts | 22 ++++-- apps/memos-local-plugin/core/config/paths.ts | 2 +- .../tests/python/test_hermes_home.py | 76 ++++++++----------- .../tests/unit/config/hermes-home.test.ts | 40 +++++----- 8 files changed, 112 insertions(+), 163 deletions(-) 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 767092c9d..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 @@ -76,9 +76,7 @@ def _resolved_runtime_home(agent: str, env: dict[str, str]) -> Path: 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" - ) + 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 index f13277a38..1da32261f 100644 --- a/apps/memos-local-plugin/adapters/hermes/memos_provider/hermes_home.py +++ b/apps/memos-local-plugin/adapters/hermes/memos_provider/hermes_home.py @@ -23,8 +23,12 @@ from pathlib import Path -def _expand(value: str, env: dict[str, str] | None = None) -> Path: - """Expand ~ using the caller-supplied HOME if available. +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 @@ -33,22 +37,25 @@ def _expand(value: str, env: dict[str, str] | None = None) -> Path: against the current working directory. """ src = value - home = "" - if env is not None: - home = env.get("HOME", "").strip() or env.get("USERPROFILE", "").strip() + 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("~/") or src.startswith("~\\"): + 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}" - ) + raise ValueError(f"named-user tilde paths are not supported: {value!r}") return Path(src).resolve() @@ -62,21 +69,17 @@ def resolve_hermes_home( respectively; callers pass them explicitly to keep the resolver unit-testable without process mutation. """ - effective_env: dict[str, str] - if env is None: - effective_env = dict(os.environ) - else: - effective_env = dict(env) + 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) + 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) / "hermes" + 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 @@ -86,10 +89,14 @@ def resolve_hermes_home( or effective_env.get("HOME", "").strip() or str(Path.home()) ) - return _expand(home, effective_env) / "AppData" / "Local" / "hermes" - - home = effective_env.get("HOME", "").strip() or str(Path.home()) - return _expand(home, effective_env) / ".hermes" + 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 e60eb733b..87ffd83ff 100644 --- a/apps/memos-local-plugin/bridge.cts +++ b/apps/memos-local-plugin/bridge.cts @@ -92,47 +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 { - if (agent === "hermes") { - // Honour HERMES_HOME / %LOCALAPPDATA%\hermes so the PID file lives - // inside the same Hermes home the daemon reads (issue #2221). - return path.join(resolveHermesHomeSync(), "memos-plugin", "daemon", filename); - } - return path.join( - process.env.HOME ?? "/tmp", - `.${agent}`, - "memos-plugin", - "daemon", - filename, - ); -} - -// `main()` is async and the ESM `resolveHermesHome` import happens lazily -// inside it. `pidFilePath` runs *before* that dynamic import completes, -// so we mirror the same tiny resolver here to avoid loading ESM twice. -function resolveHermesHomeSync(): string { - const env = process.env; - const explicit = (env["HERMES_HOME"] ?? "").trim(); - if (explicit) return path.resolve(expandUserSync(explicit)); - - if (process.platform === "win32") { - const local = (env["LOCALAPPDATA"] ?? "").trim(); - if (local) return path.resolve(path.join(expandUserSync(local), "hermes")); - const home = env["USERPROFILE"] || env["HOME"] || "/tmp"; - return path.resolve(path.join(expandUserSync(home), "AppData", "Local", "hermes")); - } - const home = env["HOME"] || "/tmp"; - return path.resolve(path.join(expandUserSync(home), ".hermes")); -} - -function expandUserSync(value: string): string { - const env = process.env; - const home = env["HOME"] || env["USERPROFILE"] || ""; - if (value === "~") return home || value; - if (value.startsWith("~/") || value.startsWith("~\\")) { - return path.join(home || "", value.slice(2)); - } - return value; +function pidFilePath(runtimeHome: string, filename: string = PID_FILENAME): string { + return path.join(runtimeHome, "daemon", filename); } function readPidFile(pidPath: string): number | null { @@ -192,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); @@ -304,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 0b248c7e2..6ea9d5d65 100644 --- a/apps/memos-local-plugin/bridge.mts +++ b/apps/memos-local-plugin/bridge.mts @@ -33,7 +33,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; -import { resolveHermesHome } from "./core/config/hermes-home.js"; +import { resolveHome } from "./core/config/paths.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -80,24 +80,8 @@ function parseArgs(argv: readonly string[]): BridgeArgs { const PID_FILENAME = "bridge.pid"; -function pidFilePath(agent: string): string { - if (agent === "hermes") { - // Honour HERMES_HOME / %LOCALAPPDATA%\hermes so the PID file lives - // inside the same Hermes home the daemon reads (issue #2221). - return path.join( - resolveHermesHome(), - "memos-plugin", - "daemon", - PID_FILENAME, - ); - } - return path.join( - process.env.HOME ?? "/tmp", - `.${agent}`, - "memos-plugin", - "daemon", - PID_FILENAME, - ); +function pidFilePath(runtimeHome: string): string { + return path.join(runtimeHome, "daemon", PID_FILENAME); } function readPidFile(pidPath: string): number | null { @@ -157,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); @@ -246,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 index 1ec7653ef..50e4cb3c2 100644 --- a/apps/memos-local-plugin/core/config/hermes-home.ts +++ b/apps/memos-local-plugin/core/config/hermes-home.ts @@ -16,7 +16,7 @@ */ import { homedir } from "node:os"; -import { resolve as pathResolve, join } from "node:path"; +import { posix, win32 } from "node:path"; /** * Structural env type; matches `process.env` but stays usable in tests @@ -51,35 +51,41 @@ function resolveHome(env: EnvLike, platform: NodeJS.Platform): string { * expansion is deliberately out of scope, so it raises rather than * silently resolving relative to CWD. */ -function expandHomePath(value: string, home: string): string { +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 = join(home, out.slice(2)); + 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 pathResolve(out); + 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); + if (hermesHome) return expandHomePath(hermesHome, home, platform); if (platform === "win32") { const localAppData = (env["LOCALAPPDATA"] ?? "").trim(); - if (localAppData) return pathResolve(join(localAppData, "hermes")); + if (localAppData) return path.resolve(path.join(localAppData, "hermes")); // Match Hermes' own fallback when LOCALAPPDATA is unset. - return pathResolve(join(home, "AppData", "Local", "hermes")); + return path.resolve(path.join(home, "AppData", "Local", "hermes")); } - return pathResolve(join(home, ".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 4cd6e4624..dd870ec69 100644 --- a/apps/memos-local-plugin/core/config/paths.ts +++ b/apps/memos-local-plugin/core/config/paths.ts @@ -66,7 +66,7 @@ export function resolveHome(agent: AgentKind, defaultHome?: string): ResolvedHom 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/tests/python/test_hermes_home.py b/apps/memos-local-plugin/tests/python/test_hermes_home.py index f7ad0593b..4e2726b56 100644 --- a/apps/memos-local-plugin/tests/python/test_hermes_home.py +++ b/apps/memos-local-plugin/tests/python/test_hermes_home.py @@ -43,63 +43,58 @@ def test_hermes_home_env_wins_on_posix(self) -> None: "LOCALAPPDATA": "C:\\Users\\bob\\AppData\\Local", } got = resolve_hermes_home(env=env, platform="linux") - self.assertEqual(str(got), "/tmp/custom-hermes-home") + 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": "D:\\hermes-workshop", - "LOCALAPPDATA": "C:\\Users\\bob\\AppData\\Local", + "HERMES_HOME": "/tmp/hermes-workshop", + "LOCALAPPDATA": "/tmp/localappdata", } got = resolve_hermes_home(env=env, platform="win32") - # HERMES_HOME override must be returned as-is (resolved), with no - # suffix added. Comparing to Path(...).resolve() keeps the test - # portable — both sides go through the same normalisation. - self.assertEqual(str(got), str(Path("D:\\hermes-workshop").resolve())) + 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": "C:\\Users\\bob\\AppData\\Local"} + env = {"LOCALAPPDATA": "/tmp/localappdata"} got = resolve_hermes_home(env=env, platform="win32") - got_str = str(got).replace("/", "\\") - # The last two segments must always be AppData\Local\hermes. - self.assertTrue( - got_str.endswith("AppData\\Local\\hermes") - or got_str.endswith("AppData\\Local\\hermes\\"), - f"expected LOCALAPPDATA/hermes, got {got_str!r}", + 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 - # Use a POSIX-shaped HOME so the test is portable — Path("C:\\…") - # is a relative-filename-with-backslash on Linux/macOS, which - # would resolve against CWD and give misleading results. - env = {"HOME": "/home/tester"} + env = { + "USERPROFILE": "/home/windows-user", + "HOME": "/home/posix-user", + } got = resolve_hermes_home(env=env, platform="win32") - got_str = str(got).replace("\\", "/") - # Falls back to /AppData/Local/hermes on Windows. - self.assertTrue( - got_str.endswith("/AppData/Local/hermes"), - f"expected …/AppData/Local/hermes, got {got_str!r}", - ) - self.assertTrue( - got_str.startswith("/home/tester/"), - f"expected HOME-rooted path, got {got_str!r}", + 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") - # Path.resolve() normalizes; check by suffix. - self.assertTrue( - str(got).endswith("/.hermes") or str(got).endswith("\\.hermes"), - f"expected ~/.hermes, got {got!r}", - ) + 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.""" @@ -130,13 +125,10 @@ def test_memos_provider_fallback_uses_hermes_home_env(self) -> None: os.environ.pop("MEMOS_CONFIG_FILE", None) got = memos_provider._resolved_memos_runtime_home() - got_str = str(got) - # /tmp/regression-2221-hermes/memos-plugin - self.assertTrue( - got_str.endswith("/memos-plugin") or got_str.endswith("\\memos-plugin"), - f"expected …/memos-plugin, got {got_str!r}", + self.assertEqual( + got, + Path("/tmp/regression-2221-hermes/memos-plugin").resolve(), ) - self.assertIn("regression-2221-hermes", got_str) finally: if original is None: os.environ.pop("HERMES_HOME", None) @@ -155,11 +147,9 @@ def test_bridge_client_fallback_uses_hermes_home_env(self) -> None: "HOME": "/home/tester", } got = bridge_client._resolved_runtime_home("hermes", env) - got_str = str(got) - self.assertIn("regression-2221-bridge", got_str) - self.assertTrue( - got_str.endswith("/memos-plugin") or got_str.endswith("\\memos-plugin"), - f"expected …/memos-plugin, got {got_str!r}", + self.assertEqual( + got, + Path("/tmp/regression-2221-bridge/memos-plugin").resolve(), ) 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 index 1fbdded55..ce0bffda3 100644 --- a/apps/memos-local-plugin/tests/unit/config/hermes-home.test.ts +++ b/apps/memos-local-plugin/tests/unit/config/hermes-home.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { join } from "node:path"; +import { posix, win32 } from "node:path"; import { resolveHermesHome } from "../../../core/config/hermes-home.js"; @@ -16,8 +16,7 @@ describe("config/hermes-home", () => { { HERMES_HOME: "/tmp/custom-hermes", HOME: "/home/alice" }, "linux", ); - // Path.resolve normalises; ensure the override is preserved. - expect(home.endsWith("custom-hermes")).toBe(true); + expect(home).toBe(posix.resolve("/tmp/custom-hermes")); }); it("HERMES_HOME env overrides everything on Windows", () => { @@ -28,7 +27,7 @@ describe("config/hermes-home", () => { }, "win32", ); - expect(home.includes("hermes-workshop")).toBe(true); + expect(home).toBe(win32.resolve("D:\\hermes-workshop")); }); it("Windows uses LOCALAPPDATA/hermes when HERMES_HOME is unset", () => { @@ -36,29 +35,36 @@ describe("config/hermes-home", () => { { LOCALAPPDATA: "C:\\Users\\bob\\AppData\\Local" }, "win32", ); - // Node's path.resolve on POSIX won't touch drive letters, but the - // final segment must always be "hermes" and the preceding segment - // "Local". - const normalized = home.replace(/\\/g, "/"); - expect(normalized.endsWith("/hermes")).toBe(true); - expect(normalized.includes("AppData/Local/hermes")).toBe(true); + 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({ HOME: "/mnt/user" }, "win32"); - const normalized = home.replace(/\\/g, "/"); - expect(normalized.endsWith("AppData/Local/hermes")).toBe(true); + 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"); - const normalized = home.replace(/\\/g, "/"); - expect(normalized).toBe(join("/home/alice", ".hermes")); + expect(home).toBe(posix.resolve("/home/alice/.hermes")); }); it("POSIX default on darwin honours HOME", () => { const home = resolveHermesHome({ HOME: "/Users/carol" }, "darwin"); - const normalized = home.replace(/\\/g, "/"); - expect(normalized).toBe(join("/Users/carol", ".hermes")); + expect(home).toBe(posix.resolve("/Users/carol/.hermes")); }); });