mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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>
This commit is contained in:
@@ -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'
|
||||
]),
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -16,9 +16,9 @@ import {
|
||||
removeOrcaStatusContent,
|
||||
updateConfigContent
|
||||
} from './hermes-config-yaml'
|
||||
import { getHermesHome } from './hermes-default-home'
|
||||
import {
|
||||
getConfigPath,
|
||||
getHermesHome,
|
||||
getPluginDir,
|
||||
getPluginFilesState,
|
||||
readConfigFile,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user