fix(hermes): keep named profiles' orca-status copy and pane passthrough current (#33742)

* fix(hermes): keep named profiles' orca-status copy and pane passthrough current

A Hermes named profile loads plugins and terminal.client_env_passthrough from
profiles/<name>/, so a profile that carries Orca's managed plugin (e.g. cloned
with `hermes profile create --clone`) never got the pane-identity passthrough
or plugin updates. Install/remove now also update those profiles, locally and
over SSH.

Refs #33591

* fix(hermes): never re-enable orca-status in a profile; isolate profile failures

Only refresh named profiles that keep orca-status enabled and not disabled,
match the local managed check remotely (both plugin files), and skip a profile
or an unlistable profiles dir with a warning instead of failing the install.

* fix(hermes): write a profile's config before removing its plugin
This commit is contained in:
Neil
2026-10-25 20:04:02 -07:00
committed by GitHub
parent 15a177ccf2
commit 19fecada22
6 changed files with 199 additions and 16 deletions
@@ -93,12 +93,20 @@ export function createAgentHookMemorySftp(initialFiles: Record<string, string> =
done(missing(path))
return
}
done(null, [])
const prefix = `${path}/`
const names = [...fs.files.keys(), ...fs.dirs]
.filter((entry) => entry.startsWith(prefix))
.map((entry) => entry.slice(prefix.length).split('/')[0])
done(
null,
[...new Set(names)].map((filename) => ({ filename }))
)
},
mkdir: (path: string, done: (error: unknown) => void) => {
fs.dirs.add(path)
done(null)
}
} as unknown as SFTPWrapper
return { sftp, fs }
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: installers call only these members.
return { sftp: sftp as unknown as SFTPWrapper, fs }
}
@@ -797,6 +797,44 @@ describe('remote hook service installers', () => {
expect(fs.files.get('/home/dev/.hermes/config.yaml')).toContain('orca-status')
})
it('refreshes remote named profiles that keep the plugin enabled, and only those', async () => {
const marker = '# Managed by Orca. Do not edit; changes may be overwritten.\n'
const profiles = '/home/dev/.hermes/profiles'
const disabledConfig = 'plugins:\n enabled: [orca-status]\n disabled: [orca-status]\n'
const { sftp, fs } = createFakeSftp({
[`${profiles}/work/plugins/orca-status/plugin.yaml`]: marker,
[`${profiles}/work/plugins/orca-status/__init__.py`]: marker,
[`${profiles}/work/config.yaml`]: 'plugins:\n enabled: [orca-status]\n',
[`${profiles}/off/plugins/orca-status/plugin.yaml`]: marker,
[`${profiles}/off/plugins/orca-status/__init__.py`]: marker,
[`${profiles}/off/config.yaml`]: disabledConfig,
[`${profiles}/user/plugins/orca-status/plugin.yaml`]: marker,
[`${profiles}/user/plugins/orca-status/__init__.py`]: 'user code\n',
[`${profiles}/user/config.yaml`]: 'plugins:\n enabled: [orca-status]\n'
})
fs.dirs.add(profiles)
expect((await new HermesHookService().installRemote(sftp, '/home/dev')).state).toBe('installed')
expect(fs.files.get(`${profiles}/work/config.yaml`)).toContain('ORCA_AGENT_PANE')
expect(fs.files.get(`${profiles}/work/plugins/orca-status/__init__.py`)).toContain(
'/hook/hermes'
)
expect(fs.files.get(`${profiles}/off/config.yaml`)).toBe(disabledConfig)
expect(fs.files.get(`${profiles}/user/plugins/orca-status/__init__.py`)).toBe('user code\n')
})
it('still installs the default Hermes profile when remote profiles cannot be listed', async () => {
const { sftp, fs } = createFakeSftp()
sftp.readdir = (_path, callback) => {
callback(Object.assign(new Error('permission denied'), { code: 3 }), [])
}
vi.spyOn(console, 'warn').mockImplementation(() => {})
expect((await new HermesHookService().installRemote(sftp, '/home/dev')).state).toBe('installed')
expect(fs.files.get('/home/dev/.hermes/config.yaml')).toContain('orca-status')
})
it('does not overwrite a remote user-authored Amp plugin file', async () => {
const { sftp, fs } = createFakeSftp({
'/home/dev/.config/amp/plugins/orca-agent-status.ts':
+18 -2
View File
@@ -92,13 +92,17 @@ function updatePassthrough(
: { ...config, terminal: { ...terminal, client_env_passthrough: next } }
}
export function installOrcaStatusConfig(config: HermesConfig): HermesConfig {
return updatePassthrough(enablePlugin(config), (names) => [
function addOrcaPassthrough(config: HermesConfig): HermesConfig {
return updatePassthrough(config, (names) => [
...names,
...HERMES_CLIENT_ENV_PASSTHROUGH.filter((name) => !names.includes(name))
])
}
export function installOrcaStatusConfig(config: HermesConfig): HermesConfig {
return addOrcaPassthrough(enablePlugin(config))
}
export function removeOrcaStatusConfig(config: HermesConfig): HermesConfig {
return updatePassthrough(disablePlugin(config), (names) =>
names.filter((name) => !HERMES_CLIENT_ENV_PASSTHROUGH.includes(name))
@@ -160,3 +164,15 @@ export function getConfigEnablement(config: HermesConfig): {
detail: null
}
}
/** A named profile's passthrough, refreshed only while the user keeps orca-status enabled there; null skips the profile. */
export function refreshProfileOrcaStatusContent(content: string | null): {
content: string | null
} {
const parsed = parseHermesConfig(content)
if (!parsed.ok) {
return { content: null }
}
const { enabled, disabled } = getConfigEnablement(parsed.config)
return enabled && !disabled ? updateConfigContent(content, addOrcaPassthrough) : { content: null }
}
+19 -5
View File
@@ -1,4 +1,4 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import type { ConfigParseResult, HermesConfig } from './hermes-config-yaml'
@@ -13,12 +13,26 @@ import {
getPluginManifest
} from './hermes-managed-plugin-source'
export function getConfigPath(): string {
return join(getHermesHome(), 'config.yaml')
export function getConfigPath(home = getHermesHome()): string {
return join(home, 'config.yaml')
}
export function getPluginDir(): string {
return join(getHermesHome(), 'plugins', HERMES_PLUGIN_NAME)
export function getPluginDir(home = getHermesHome()): string {
return join(home, 'plugins', HERMES_PLUGIN_NAME)
}
/** Named profiles (`hermes -p <name>`) that carry an Orca-managed copy of the plugin. */
export function listManagedProfileHomes(): string[] {
const profilesDir = join(getHermesHome(), 'profiles')
let names: string[]
try {
names = readdirSync(profilesDir)
} catch {
return []
}
return names
.map((name) => join(profilesDir, name))
.filter((home) => getPluginFilesState(getPluginDir(home)).managed)
}
function getManifestPath(pluginDir = getPluginDir()): string {
+37
View File
@@ -121,6 +121,43 @@ describe('HermesHookService', () => {
expect(removed).toContain('- MY_VAR # mine')
})
it('refreshes named profiles that keep the plugin enabled, and only those', () => {
const stale = '# Managed by Orca. Do not edit; changes may be overwritten.\n# stale\n'
// `hermes profile create --clone` copies the plugin but not later passthrough edits.
const profile = (name: string, config: string): string => {
const home = join(homeDir, 'profiles', name)
const pluginDir = join(home, 'plugins', _internals.HERMES_PLUGIN_NAME)
mkdirSync(pluginDir, { recursive: true })
writeFileSync(join(pluginDir, 'plugin.yaml'), _internals.getPluginManifest())
writeFileSync(join(pluginDir, '__init__.py'), stale)
writeFileSync(join(home, 'config.yaml'), config)
return home
}
const pluginSource = (home: string): string =>
readFileSync(join(home, 'plugins', _internals.HERMES_PLUGIN_NAME, '__init__.py'), 'utf-8')
const broken = profile('broken', '')
rmSync(join(broken, 'config.yaml'))
mkdirSync(join(broken, 'config.yaml'))
const work = profile('work', 'plugins:\n enabled: [orca-status]\n')
const disabledConfig = 'plugins:\n enabled: [orca-status]\n disabled: [orca-status]\n'
const disabled = profile('off', disabledConfig)
const service = new HermesHookService()
expect(service.install().state).toBe('installed')
expect(parse(readFileSync(join(work, 'config.yaml'), 'utf-8')).terminal).toEqual({
client_env_passthrough: [...HERMES_CLIENT_ENV_PASSTHROUGH]
})
expect(pluginSource(work)).toBe(_internals.getPluginInitSource())
expect(readFileSync(join(disabled, 'config.yaml'), 'utf-8')).toBe(disabledConfig)
expect(pluginSource(disabled)).toBe(stale)
service.remove()
expect(parse(readFileSync(join(work, 'config.yaml'), 'utf-8'))).toEqual({
plugins: { enabled: [] },
terminal: { client_env_passthrough: [] }
})
})
it.each([
['an anchored terminal setting', 'terminal:\n env: &env {A: "1"}\nshared: *env\n', true],
['a malformed passthrough', 'terminal:\n client_env_passthrough: FOO\n', false],
+76 -6
View File
@@ -3,6 +3,7 @@ import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
listRemoteDirectory,
readTextFileRemote,
writeTextFileRemoteAtomic
} from '../agent-hooks/installer-utils-remote'
@@ -13,6 +14,7 @@ import {
getConfigEnablement,
installOrcaStatusContent,
parseHermesConfig,
refreshProfileOrcaStatusContent,
removeOrcaStatusContent,
updateConfigContent
} from './hermes-config-yaml'
@@ -21,12 +23,14 @@ import {
getConfigPath,
getPluginDir,
getPluginFilesState,
listManagedProfileHomes,
readConfigFile,
writeConfigFile,
writePluginFiles
} from './hermes-home-filesystem'
import {
HERMES_EVENTS,
HERMES_PLUGIN_MARKER,
HERMES_PLUGIN_NAME,
getPluginInitSource,
getPluginManifest
@@ -65,6 +69,72 @@ function buildStatus(configPath: string, config: HermesConfig): AgentHookInstall
}
}
// Why: each named profile loads plugins and client_env_passthrough from its own home, so a copy there must track Orca's.
function updateManagedProfiles(
update: (content: string | null) => { content: string | null },
onUpdated: (pluginDir: string) => void
): void {
for (const home of listManagedProfileHomes()) {
try {
const configPath = getConfigPath(home)
const parsed = readConfigFile(configPath)
const next = parsed.ok ? update(parsed.content) : { content: null }
if (!parsed.ok || next.content === null) {
continue
}
// Config first: a failed write must not leave orca-status enabled without its plugin.
if (next.content !== parsed.content) {
writeConfigFile(configPath, next.content)
}
onUpdated(getPluginDir(home))
} catch (error) {
console.warn(`[hermes] skipped profile ${home}:`, error)
}
}
}
async function writeRemoteHome(
sftp: SFTPWrapper,
home: string,
existing: string | null,
content: string
): Promise<void> {
const pluginDir = `${home}/plugins/${HERMES_PLUGIN_NAME}`
await writeTextFileRemoteAtomic(sftp, `${pluginDir}/plugin.yaml`, getPluginManifest())
await writeTextFileRemoteAtomic(sftp, `${pluginDir}/__init__.py`, getPluginInitSource())
if (content !== existing) {
await writeTextFileRemoteAtomic(sftp, `${home}/config.yaml`, content)
}
}
async function installRemoteManagedProfiles(sftp: SFTPWrapper, profilesDir: string): Promise<void> {
const names = await listRemoteDirectory(sftp, profilesDir).catch((error: unknown) => {
console.warn(`[hermes] could not list remote profiles in ${profilesDir}:`, error)
return null
})
for (const name of names ?? []) {
const home = `${profilesDir}/${name}`
const pluginDir = `${home}/plugins/${HERMES_PLUGIN_NAME}`
try {
const files = await Promise.all(
['plugin.yaml', '__init__.py'].map((file) =>
readTextFileRemote(sftp, `${pluginDir}/${file}`)
)
)
if (!files.every((file) => file?.includes(HERMES_PLUGIN_MARKER))) {
continue
}
const existing = await readTextFileRemote(sftp, `${home}/config.yaml`)
const next = refreshProfileOrcaStatusContent(existing)
if (next.content !== null) {
await writeRemoteHome(sftp, home, existing, next.content)
}
} catch (error) {
console.warn(`[hermes] skipped remote profile ${home}:`, error)
}
}
}
function stripTrailingSlash(path: string): string {
return path.replace(/\/+$/, '')
}
@@ -112,13 +182,13 @@ export class HermesHookService {
if (next.content !== parsed.content) {
writeConfigFile(configPath, next.content)
}
updateManagedProfiles(refreshProfileOrcaStatusContent, writePluginFiles)
return this.getStatus()
}
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
const remoteRoot = stripTrailingSlash(remoteHome)
const remoteConfigPath = `${remoteRoot}/.hermes/config.yaml`
const remotePluginDir = `${remoteRoot}/.hermes/plugins/${HERMES_PLUGIN_NAME}`
try {
const existing = await readTextFileRemote(sftp, remoteConfigPath)
const next = installOrcaStatusContent(existing)
@@ -131,11 +201,8 @@ export class HermesHookService {
detail: `Could not parse remote Hermes config.yaml: ${next.detail ?? 'unknown error'}`
}
}
await writeTextFileRemoteAtomic(sftp, `${remotePluginDir}/plugin.yaml`, getPluginManifest())
await writeTextFileRemoteAtomic(sftp, `${remotePluginDir}/__init__.py`, getPluginInitSource())
if (next.content !== existing) {
await writeTextFileRemoteAtomic(sftp, remoteConfigPath, next.content)
}
await writeRemoteHome(sftp, `${remoteRoot}/.hermes`, existing, next.content)
await installRemoteManagedProfiles(sftp, `${remoteRoot}/.hermes/profiles`)
return {
agent: 'hermes',
state: 'installed',
@@ -183,6 +250,9 @@ export class HermesHookService {
if (getPluginFilesState(pluginDir).managed) {
rmSync(pluginDir, { recursive: true, force: true })
}
updateManagedProfiles(removeOrcaStatusContent, (dir) =>
rmSync(dir, { recursive: true, force: true })
)
return this.getStatus()
}
}