mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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':
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user