diff --git a/src/main/agent-hooks/agent-hook-memory-sftp.test-fixture.ts b/src/main/agent-hooks/agent-hook-memory-sftp.test-fixture.ts index 6b3fb75adb6..2e94e1405d2 100644 --- a/src/main/agent-hooks/agent-hook-memory-sftp.test-fixture.ts +++ b/src/main/agent-hooks/agent-hook-memory-sftp.test-fixture.ts @@ -93,12 +93,20 @@ export function createAgentHookMemorySftp(initialFiles: Record = 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 } } diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 59370b08c37..7b6bb62da27 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -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': diff --git a/src/main/hermes/hermes-config-yaml.ts b/src/main/hermes/hermes-config-yaml.ts index 639fdd09153..17b534a4001 100644 --- a/src/main/hermes/hermes-config-yaml.ts +++ b/src/main/hermes/hermes-config-yaml.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 } +} diff --git a/src/main/hermes/hermes-home-filesystem.ts b/src/main/hermes/hermes-home-filesystem.ts index cc504b59c4d..d4daa431c8f 100644 --- a/src/main/hermes/hermes-home-filesystem.ts +++ b/src/main/hermes/hermes-home-filesystem.ts @@ -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 `) 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 { diff --git a/src/main/hermes/hook-service.test.ts b/src/main/hermes/hook-service.test.ts index ce6ec3beb08..e4c1b80a9d2 100644 --- a/src/main/hermes/hook-service.test.ts +++ b/src/main/hermes/hook-service.test.ts @@ -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], diff --git a/src/main/hermes/hook-service.ts b/src/main/hermes/hook-service.ts index 2bd1dc0f674..2fbb478e0bf 100644 --- a/src/main/hermes/hook-service.ts +++ b/src/main/hermes/hook-service.ts @@ -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 { + 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 { + 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 { 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() } }