From 10005e5d15699fa527fcfea87c8dc617842189ff Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:56:51 -0700 Subject: [PATCH] fix(hermes): use the Windows %LOCALAPPDATA%\hermes home for hooks and session history (#33413) Hermes home resolves like Hermes itself on Windows (HERMES_HOME, else %LOCALAPPDATA%\hermes, else a legacy ~\.hermes that alone exists), shared by hook install/status, skill discovery and session reading; session reading also honors HERMES_HOME. Fixes #24966. Supersedes #25058. Co-authored-by: innocarpe <2222333+innocarpe@users.noreply.github.com> --- .../ai-vault/session-scanner-agent-sources.ts | 4 +- src/main/hermes/hermes-default-home.ts | 56 +++++++++++++++++++ src/main/hermes/hermes-home-filesystem.ts | 7 +-- src/main/hermes/hermes-windows-home.test.ts | 44 +++++++++++++++ src/main/hermes/hook-service.ts | 2 +- .../skills/skill-provider-runtime-roots.ts | 37 ++---------- 6 files changed, 110 insertions(+), 40 deletions(-) create mode 100644 src/main/hermes/hermes-default-home.ts create mode 100644 src/main/hermes/hermes-windows-home.test.ts diff --git a/src/main/ai-vault/session-scanner-agent-sources.ts b/src/main/ai-vault/session-scanner-agent-sources.ts index e9575a60b0c..1f6a516613c 100644 --- a/src/main/ai-vault/session-scanner-agent-sources.ts +++ b/src/main/ai-vault/session-scanner-agent-sources.ts @@ -28,6 +28,7 @@ import { sessionRootDirs, wslHomeSessionDirs } from './session-scanner-roots' +import { getHermesHome } from '../hermes/hermes-default-home' import { SUBAGENT_DIR_NAME } from './session-scanner-subagent-transcripts' import type { AiVaultScanOptions } from './session-scanner-types' import { normalizeAgentSessionsDir, primeAgentSessionsDirFromEnv } from './session-scanner-values' @@ -40,7 +41,6 @@ const COPILOT_SESSIONS_DIR = join( ) const CURSOR_PROJECTS_DIR = join(homedir(), '.cursor', 'projects') const CODEBUDDY_PROJECTS_DIR = join(homedir(), '.codebuddy', 'projects') -const HERMES_SESSIONS_DIR = join(homedir(), '.hermes', 'sessions') const ROVO_SESSIONS_DIR = join(homedir(), '.rovodev', 'sessions') export function piSessionHistorySessionsDir(): string { return normalizeAgentSessionsDir( @@ -182,7 +182,7 @@ export const AI_VAULT_AGENT_SOURCES: AiVaultAgentSourceTable = { }, hermes: { rootDirs: (options, wslHomeDirs) => - sessionRootDirs(options.hermesSessionsDir ?? HERMES_SESSIONS_DIR, wslHomeDirs, [ + sessionRootDirs(options.hermesSessionsDir ?? join(getHermesHome(), 'sessions'), wslHomeDirs, [ '.hermes', 'sessions' ]), diff --git a/src/main/hermes/hermes-default-home.ts b/src/main/hermes/hermes-default-home.ts new file mode 100644 index 00000000000..afb5a081f7a --- /dev/null +++ b/src/main/hermes/hermes-default-home.ts @@ -0,0 +1,56 @@ +import { statSync } from 'node:fs' +import { homedir } from 'node:os' +import { isAbsolute, join, resolve } from 'node:path' + +const LOCAL_APP_DATA_MAX_LENGTH = 32_768 + +export type DefaultHermesHomeInput = { + homeDir?: string + env?: NodeJS.ProcessEnv + platform?: NodeJS.Platform + directoryExists?: (candidate: string) => boolean +} + +function usableLocalAppData(value: string | undefined): string | null { + const candidate = value?.trim() + if ( + !candidate || + candidate.length > LOCAL_APP_DATA_MAX_LENGTH || + candidate.includes('\0') || + !isAbsolute(candidate) + ) { + return null + } + return resolve(candidate) +} + +function isExistingDirectory(candidate: string): boolean { + return statSync(candidate, { throwIfNoEntry: false })?.isDirectory() === true +} + +/** + * The Hermes home when `HERMES_HOME` is unset. Windows installs land in + * `%LOCALAPPDATA%\hermes`, not a dotfolder in the user profile. The dotfolder + * still wins on Windows when it exists and the LOCALAPPDATA tree does not, + * which is the pre-LOCALAPPDATA install Hermes Desktop keeps honouring. + */ +export function resolveDefaultHermesHome(input: DefaultHermesHomeInput = {}): string { + const dotfolderHome = join(input.homeDir ?? homedir(), '.hermes') + if ((input.platform ?? process.platform) !== 'win32') { + return dotfolderHome + } + const localAppData = usableLocalAppData((input.env ?? process.env).LOCALAPPDATA) + if (!localAppData) { + return dotfolderHome + } + const localAppDataHome = join(localAppData, 'hermes') + const directoryExists = input.directoryExists ?? isExistingDirectory + return !directoryExists(localAppDataHome) && directoryExists(dotfolderHome) + ? dotfolderHome + : localAppDataHome +} + +export function getHermesHome(env: NodeJS.ProcessEnv = process.env): string { + const explicit = env.HERMES_HOME?.trim() + return explicit ? explicit : resolveDefaultHermesHome({ env }) +} diff --git a/src/main/hermes/hermes-home-filesystem.ts b/src/main/hermes/hermes-home-filesystem.ts index 9ff322d5bfa..cc504b59c4d 100644 --- a/src/main/hermes/hermes-home-filesystem.ts +++ b/src/main/hermes/hermes-home-filesystem.ts @@ -1,9 +1,9 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { homedir } from 'node:os' import { join } from 'node:path' import type { ConfigParseResult, HermesConfig } from './hermes-config-yaml' import { parseHermesConfig } from './hermes-config-yaml' +import { getHermesHome } from './hermes-default-home' import { resolveHooksJsonWritePath } from '../agent-hooks/hook-config-write-path' import { writeHooksJson } from '../agent-hooks/installer-utils' import { @@ -13,11 +13,6 @@ import { getPluginManifest } from './hermes-managed-plugin-source' -export function getHermesHome(env: NodeJS.ProcessEnv = process.env): string { - const explicit = env.HERMES_HOME?.trim() - return explicit ? explicit : join(homedir(), '.hermes') -} - export function getConfigPath(): string { return join(getHermesHome(), 'config.yaml') } diff --git a/src/main/hermes/hermes-windows-home.test.ts b/src/main/hermes/hermes-windows-home.test.ts new file mode 100644 index 00000000000..fe80428915c --- /dev/null +++ b/src/main/hermes/hermes-windows-home.test.ts @@ -0,0 +1,44 @@ +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { getHermesHome } from './hermes-default-home' +import { HermesHookService } from './hook-service' + +const realPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + +describe('Hermes home on Windows', () => { + afterEach(() => { + vi.unstubAllEnvs() + if (realPlatform) { + Object.defineProperty(process, 'platform', realPlatform) + } + }) + + it('keeps an explicit HERMES_HOME ahead of the LOCALAPPDATA default', () => { + expect(getHermesHome({ HERMES_HOME: 'D:\\profiles\\hermes', LOCALAPPDATA: tmpdir() })).toBe( + 'D:\\profiles\\hermes' + ) + }) + + it('installs and reports hook status under %LOCALAPPDATA%\\hermes (#24966)', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-hermes-win-')) + try { + const hermesHome = join(root, 'Local', 'hermes') + mkdirSync(hermesHome, { recursive: true }) + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + vi.stubEnv('LOCALAPPDATA', join(root, 'Local')) + vi.stubEnv('HERMES_HOME', '') + + expect(new HermesHookService().install().state).toBe('installed') + expect(new HermesHookService().getStatus()).toMatchObject({ + state: 'installed', + configPath: join(hermesHome, 'config.yaml'), + managedHooksPresent: true + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/hermes/hook-service.ts b/src/main/hermes/hook-service.ts index 8b4e58039a8..2bd1dc0f674 100644 --- a/src/main/hermes/hook-service.ts +++ b/src/main/hermes/hook-service.ts @@ -16,9 +16,9 @@ import { removeOrcaStatusContent, updateConfigContent } from './hermes-config-yaml' +import { getHermesHome } from './hermes-default-home' import { getConfigPath, - getHermesHome, getPluginDir, getPluginFilesState, readConfigFile, diff --git a/src/main/skills/skill-provider-runtime-roots.ts b/src/main/skills/skill-provider-runtime-roots.ts index 065f6e9cca7..eaa1538269b 100644 --- a/src/main/skills/skill-provider-runtime-roots.ts +++ b/src/main/skills/skill-provider-runtime-roots.ts @@ -1,9 +1,12 @@ -import { statSync } from 'node:fs' import { homedir } from 'node:os' import path, { isAbsolute, join, posix, resolve } from 'node:path' import { SKILL_INSTALL_PROVIDERS } from '../../shared/skill-install-providers' import { buildWslCapturedLoginShellCommand } from '../../shared/wsl-login-shell-command' import { toWindowsWslPath } from '../../shared/wsl-paths' +import { + type DefaultHermesHomeInput, + resolveDefaultHermesHome +} from '../hermes/hermes-default-home' import { runWslProcess } from '../wsl/wsl-runner' import type { SkillProviderRootOverrides } from './skill-provider-destinations' @@ -76,36 +79,8 @@ export function resolveEnvironmentHermesSkillsRoot( return hermesHome ? join(hermesHome, 'skills') : null } -function isExistingDirectory(candidate: string): boolean { - return statSync(candidate, { throwIfNoEntry: false })?.isDirectory() === true -} - -/** - * The Hermes home when `HERMES_HOME` is unset. Windows installs land in - * `%LOCALAPPDATA%\hermes`, not a dotfolder in the user profile, so the POSIX - * `~/.hermes` default discovers nothing there. The dotfolder still wins on - * Windows when it exists and the LOCALAPPDATA tree does not, which is the - * pre-LOCALAPPDATA install Hermes itself keeps honouring rather than orphaning. - */ -export function resolveDefaultHermesSkillsRoot(input: { - homeDir: string - env?: NodeJS.ProcessEnv - platform?: NodeJS.Platform - directoryExists?: (candidate: string) => boolean -}): string { - const dotfolderHome = join(input.homeDir, '.hermes') - if ((input.platform ?? process.platform) !== 'win32') { - return join(dotfolderHome, 'skills') - } - const localAppData = normalizedRoot((input.env ?? process.env).LOCALAPPDATA) - if (!localAppData) { - return join(dotfolderHome, 'skills') - } - const localAppDataHome = join(localAppData, 'hermes') - const directoryExists = input.directoryExists ?? isExistingDirectory - return !directoryExists(localAppDataHome) && directoryExists(dotfolderHome) - ? join(dotfolderHome, 'skills') - : join(localAppDataHome, 'skills') +export function resolveDefaultHermesSkillsRoot(input: DefaultHermesHomeInput): string { + return join(resolveDefaultHermesHome(input), 'skills') } export function withClaudeSkillProviderRoot(