diff --git a/src/main/hermes/hermes-config-comment-boundaries.test.ts b/src/main/hermes/hermes-config-comment-boundaries.test.ts index e1bf9ab6f16..2a8abd0af21 100644 --- a/src/main/hermes/hermes-config-comment-boundaries.test.ts +++ b/src/main/hermes/hermes-config-comment-boundaries.test.ts @@ -4,7 +4,13 @@ import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { parseDocument } from 'yaml' import { createManagedHookLocalFilesystem } from '../agent-hooks/managed-hook-local-filesystem.test-fixture' -import { disablePlugin, enablePlugin, updateConfigContent } from './hermes-config-yaml' +import { + disablePlugin, + enablePlugin, + installOrcaStatusConfig, + removeOrcaStatusConfig, + updateConfigContent +} from './hermes-config-yaml' import { HermesHookService } from './hook-service' const fixtures = [ @@ -63,6 +69,10 @@ describe.each(['\n', '\r\n'])('Hermes comment boundaries (%j)', (eol) => { expect(output.replaceAll(eol, '')).not.toContain('\n') } + const serviceOutput = updateConfigContent( + input, + operation === 'install' ? installOrcaStatusConfig : removeOrcaStatusConfig + ).content const root = mkdtempSync(join(tmpdir(), 'orca-hermes-comments-')) const home = join(root, '.hermes') mkdirSync(home) @@ -73,13 +83,13 @@ describe.each(['\n', '\r\n'])('Hermes comment boundaries (%j)', (eol) => { const service = new HermesHookService() const status = operation === 'install' ? service.install() : service.remove() expect(status.state).toBe(operation === 'install' ? 'installed' : 'not_installed') - expect(readFileSync(path, 'utf8')).toBe(output) + expect(readFileSync(path, 'utf8')).toBe(serviceOutput) expect(readFileSync(`${path}.bak`, 'utf8')).toBe(input) if (operation === 'install') { writeFileSync(path, input) const remote = await service.installRemote(createManagedHookLocalFilesystem(), root) expect(remote.state).toBe('installed') - expect(readFileSync(path, 'utf8')).toBe(output) + expect(readFileSync(path, 'utf8')).toBe(serviceOutput) } } finally { vi.unstubAllEnvs() diff --git a/src/main/hermes/hermes-config-document.test.ts b/src/main/hermes/hermes-config-document.test.ts index 744b6c23f6f..84892d4a3c6 100644 --- a/src/main/hermes/hermes-config-document.test.ts +++ b/src/main/hermes/hermes-config-document.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' import { parse } from 'yaml' -import { disablePlugin, enablePlugin, updateConfigContent } from './hermes-config-yaml' +import { + disablePlugin, + enablePlugin, + installOrcaStatusConfig, + updateConfigContent +} from './hermes-config-yaml' const fixture = [ '# Operator documentation', @@ -100,6 +105,19 @@ describe('Hermes YAML document edits', () => { expect(output.detail).toBeTruthy() }) + it('adds the passthrough beside an anchored terminal setting', () => { + const input = + 'terminal:\n docker_env: &env {A: "1"}\n backend: local\nshared_env: *env\nplugins:\n enabled: [other]\n' + const output = updateConfigContent(input, installOrcaStatusConfig).content ?? '' + expect(parse(output)).toMatchObject({ + terminal: { docker_env: { A: '1' }, backend: 'local' }, + shared_env: { A: '1' } + }) + expect(parse(output).terminal.client_env_passthrough).toContain('ORCA_AGENT_PANE') + expect(output).toContain('docker_env: &env {A: "1"}') + expect(output).toContain('shared_env: *env') + }) + it('keeps folded scalars, quoted strings and long lines in a real update', () => { const input = `# header\nprompt: >-\n First line\n Second line\nquoted: 'off'\nlong: ${'word ' .repeat(30) diff --git a/src/main/hermes/hermes-config-document.ts b/src/main/hermes/hermes-config-document.ts index 71fe4ade608..9f5c5808af0 100644 --- a/src/main/hermes/hermes-config-document.ts +++ b/src/main/hermes/hermes-config-document.ts @@ -2,7 +2,7 @@ import { isDeepStrictEqual } from 'node:util' import { isAlias, isMap, isNode, isScalar, isSeq, parseDocument, visit } from 'yaml' import type { Document, YAMLMap, YAMLSeq } from 'yaml' import type { HermesConfig } from './hermes-config-yaml' -import { applyHermesPluginSourceEdits } from './hermes-config-source-edits' +import { HERMES_EDITED_LISTS, applyHermesPluginSourceEdits } from './hermes-config-source-edits' function preserveRemovedComments(sequence: YAMLSeq, removed: unknown[], first: unknown): void { const comments = removed.flatMap((node) => @@ -17,27 +17,32 @@ function preserveRemovedComments(sequence: YAMLSeq, removed: unknown[], first: u } } -function updateStringSequence(plugins: YAMLMap, key: string, values: unknown): void { +function updateStringSequence( + section: YAMLMap, + sectionKey: string, + key: string, + values: unknown +): void { if (values === undefined) { return } if (!Array.isArray(values) || values.some((value) => typeof value !== 'string')) { - throw new Error(`Hermes plugins.${key} must be a string list`) + throw new Error(`Hermes ${sectionKey}.${key} must be a string list`) } - const sequence = plugins.get(key, true) + const sequence = section.get(key, true) if (sequence === undefined) { - plugins.set(key, values) + section.set(key, values) return } if (!isSeq(sequence)) { - throw new Error(`Hermes plugins.${key} must be a string list`) + throw new Error(`Hermes ${sectionKey}.${key} must be a string list`) } const remaining = [...values] const removed: unknown[] = [] const first = sequence.items[0] sequence.items = sequence.items.filter((item) => { if (!isScalar(item) || typeof item.value !== 'string') { - throw new Error(`Hermes plugins.${key} must contain plain string entries`) + throw new Error(`Hermes ${sectionKey}.${key} must contain plain string entries`) } const index = remaining.indexOf(item.value) if (index === -1) { @@ -55,6 +60,7 @@ function updateStringSequence(plugins: YAMLMap, key: string, values: unknown): v export function updateHermesPluginDocument( content: string, + previous: HermesConfig, next: HermesConfig ): { content: string | null; detail?: string } { try { @@ -83,46 +89,50 @@ export function updateHermesPluginDocument( throw new Error('Cannot safely edit a merged Hermes root') } - let plugins = document.get('plugins', true) - if (plugins === undefined) { - document.set('plugins', document.createNode({})) - plugins = document.get('plugins', true) - } - if (!isMap(plugins)) { - throw new Error('Hermes plugins must be a plain mapping') - } - // Aliases into a changed subtree could silently change unrelated settings. - visit(plugins, { - Node: (_key, node) => { - if (isAlias(node) || node.anchor || node.tag) { - throw new Error('Cannot safely edit Hermes plugins with anchors, aliases or tags') + for (const [sectionKey, keys] of HERMES_EDITED_LISTS) { + const nextSection = next[sectionKey] + if (isDeepStrictEqual(previous[sectionKey], nextSection)) { + continue + } + let section = document.get(sectionKey, true) + if (section === undefined) { + document.set(sectionKey, document.createNode({})) + section = document.get(sectionKey, true) + } + if (!isMap(section)) { + throw new Error(`Hermes ${sectionKey} must be a plain mapping`) + } + if (section.anchor || section.tag || section.has('<<')) { + throw new Error(`Cannot safely edit Hermes ${sectionKey} with anchors, tags or merge keys`) + } + for (const key of keys) { + const existing = section.get(key, true) + if (existing === undefined) { + continue } - }, - Pair: (_key, pair) => { - if (isScalar(pair.key) && pair.key.value === '<<') { - throw new Error('Cannot safely edit Hermes plugins with YAML merge keys') + // Aliases into an edited list could silently change unrelated settings. + visit(existing, (_key, node) => { + if (isAlias(node) || (isNode(node) && (node.anchor || node.tag))) { + throw new Error( + `Cannot safely edit Hermes ${sectionKey}.${key} with anchors, aliases or tags` + ) + } + }) + if ( + !isSeq(existing) || + existing.items.some((item) => !isScalar(item) || typeof item.value !== 'string') + ) { + throw new Error(`Hermes ${sectionKey}.${key} must be a string list`) } } - }) - for (const key of ['enabled', 'disabled']) { - const existing = plugins.get(key, true) - if ( - existing !== undefined && - (!isSeq(existing) || - existing.items.some((item) => !isScalar(item) || typeof item.value !== 'string')) - ) { - throw new Error(`Hermes plugins.${key} must be a string list`) + if (typeof nextSection !== 'object' || nextSection === null) { + throw new Error(`Hermes ${sectionKey} must be a mapping`) + } + for (const key of keys) { + if (key in nextSection) { + updateStringSequence(section, sectionKey, key, nextSection[key]) + } } - } - const nextPlugins = next.plugins - if (typeof nextPlugins !== 'object' || nextPlugins === null) { - throw new Error('Hermes plugins must be a mapping') - } - if ('enabled' in nextPlugins) { - updateStringSequence(plugins, 'enabled', nextPlugins.enabled) - } - if ('disabled' in nextPlugins) { - updateStringSequence(plugins, 'disabled', nextPlugins.disabled) } const output = applyHermesPluginSourceEdits(content, document) // Re-parse to reject edits that would change any unrelated alias-resolved value. diff --git a/src/main/hermes/hermes-config-preservation.test.ts b/src/main/hermes/hermes-config-preservation.test.ts index 9756f7a3230..8fd9d5b79eb 100644 --- a/src/main/hermes/hermes-config-preservation.test.ts +++ b/src/main/hermes/hermes-config-preservation.test.ts @@ -17,10 +17,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { parse } from 'yaml' +import { HERMES_CLIENT_ENV_PASSTHROUGH } from './hermes-managed-plugin-source' import { HermesHookService } from './hook-service' -const configured = - '# Operator comments\r\nmodel: "fixture"\r\nplugins:\r\n enabled: [orca-status]\r\n' +const configured = `# Operator comments\r\nmodel: "fixture"\r\nplugins:\r\n enabled: [orca-status]\r\nterminal:\r\n client_env_passthrough: [${HERMES_CLIENT_ENV_PASSTHROUGH.join(', ')}]\r\n` const initial = '# Recovery notes\nmodel: "001"\nprompt: |\n First\n Second\nplugins:\n enabled: [other] # choices\n' @@ -83,7 +83,7 @@ describe('Hermes config preservation on disk', () => { expect(output).toContain('model: "001"') expect(output).toContain('prompt: |\n First\n Second\n') } - expect(parse(removed)).toEqual(parse(initial)) + expect(parse(removed)).toEqual({ ...parse(initial), terminal: { client_env_passthrough: [] } }) expect(readFileSync(`${configPath}.bak`, 'utf8')).toBe(installed) }) diff --git a/src/main/hermes/hermes-config-remote-adapter.test.ts b/src/main/hermes/hermes-config-remote-adapter.test.ts index d6a57c5258a..22e39063db2 100644 --- a/src/main/hermes/hermes-config-remote-adapter.test.ts +++ b/src/main/hermes/hermes-config-remote-adapter.test.ts @@ -14,6 +14,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { parse } from 'yaml' import { createManagedHookLocalFilesystem } from '../agent-hooks/managed-hook-local-filesystem.test-fixture' +import { HERMES_CLIENT_ENV_PASSTHROUGH } from './hermes-managed-plugin-source' import { HermesHookService } from './hook-service' describe('Hermes remote installer through a local filesystem adapter (no SSH)', () => { @@ -29,7 +30,7 @@ describe('Hermes remote installer through a local filesystem adapter (no SSH)', afterEach(() => rmSync(directory, { recursive: true, force: true })) it('does not rewrite a configured CRLF file or an existing backup', async () => { - const initial = '# operator notes\r\nplugins:\r\n enabled: [zeta, orca-status, alpha]\r\n' + const initial = `# operator notes\r\nplugins:\r\n enabled: [zeta, orca-status, alpha]\r\nterminal:\r\n client_env_passthrough: [${HERMES_CLIENT_ENV_PASSTHROUGH.join(', ')}]\r\n` writeFileSync(configPath, initial) writeFileSync(`${configPath}.bak`, 'recovery point') const before = statSync(configPath) @@ -85,7 +86,8 @@ describe('Hermes remote installer through a local filesystem adapter (no SSH)', expect(installed).toContain(`prompt: |${eol} First${eol} Second`) expect(parse(installed)).toEqual({ ...parse(input), - plugins: { enabled: ['other', 'orca-status'] } + plugins: { enabled: ['other', 'orca-status'] }, + terminal: { client_env_passthrough: [...HERMES_CLIENT_ENV_PASSTHROUGH] } }) const before = statSync(configPath) expect((await service.installRemote(filesystem, directory)).state).toBe('installed') diff --git a/src/main/hermes/hermes-config-source-edits.ts b/src/main/hermes/hermes-config-source-edits.ts index 17b770e8abd..54becf32964 100644 --- a/src/main/hermes/hermes-config-source-edits.ts +++ b/src/main/hermes/hermes-config-source-edits.ts @@ -4,6 +4,11 @@ import type { Node, YAMLMap, YAMLSeq } from 'yaml' type SourceEdit = { start: number; end: number; text: string } +export const HERMES_EDITED_LISTS = [ + ['plugins', ['enabled', 'disabled']], + ['terminal', ['client_env_passthrough']] +] as const + function columnAt(source: string, offset: number): number { return offset - source.lastIndexOf('\n', offset - 1) - 1 } @@ -79,40 +84,60 @@ function insertPair( export function applyHermesPluginSourceEdits(source: string, document: Document): string { const printed = document.toString({ lineWidth: 0 }) - const original = parseDocument(source, { keepSourceTokens: true }) const updated = parseDocument(printed) - if (!isMap(original.contents)) { + if (!isMap(parseDocument(source).contents)) { return source.includes('\r\n') ? printed.replaceAll('\n', '\r\n') : printed } if (!isMap(updated.contents)) { throw new Error('Missing updated Hermes mapping') } - const plugins = original.get('plugins', true) - const updatedPlugins = updated.get('plugins', true) - if (!isMap(updatedPlugins)) { - throw new Error('Missing updated Hermes plugins') + // Why: a replaced list's range can run past the root mapping, where another section inserts. + let result = source + for (const [sectionKey, keys] of HERMES_EDITED_LISTS) { + result = applySectionEdits(result, printed, document, updated.contents, sectionKey, keys) + } + return result +} + +function applySectionEdits( + source: string, + printed: string, + document: Document, + updatedRoot: YAMLMap, + sectionKey: string, + keys: readonly string[] +): string { + const original = parseDocument(source, { keepSourceTokens: true }) + if (!isMap(original.contents)) { + throw new Error('Unexpected Hermes root mapping') + } + const section = original.get(sectionKey, true) + const updatedSection = updatedRoot.get(sectionKey, true) + // Edited sections are always mappings; anything else was left untouched. + if (!isMap(updatedSection)) { + return source } const edits: SourceEdit[] = [] - if (plugins === undefined) { - edits.push(insertPair(source, printed, original.contents, updated.contents, 'plugins')) + if (section === undefined) { + edits.push(insertPair(source, printed, original.contents, updatedRoot, sectionKey)) } else { - if (!isMap(plugins)) { - throw new Error('Unexpected Hermes plugins mapping') + if (!isMap(section)) { + throw new Error(`Unexpected Hermes ${sectionKey} mapping`) } - for (const key of ['enabled', 'disabled']) { - const previous = plugins.get(key, true) - const next = updatedPlugins.get(key, true) + for (const key of keys) { + const previous = section.get(key, true) + const next = updatedSection.get(key, true) if (!isNode(next)) { continue } if (previous === undefined) { - edits.push(insertPair(source, printed, plugins, updatedPlugins, key, original.contents)) + edits.push(insertPair(source, printed, section, updatedSection, key, original.contents)) } else if (isNode(previous) && !isDeepStrictEqual(previous.toJSON(), next.toJSON())) { - const edited = document.createNode(document.getIn(['plugins', key], true)) + const edited = document.createNode(document.getIn([sectionKey, key], true)) if (!isSeq(edited)) { - throw new Error('Missing edited Hermes plugin list') + throw new Error(`Missing edited Hermes ${sectionKey} list`) } - edits.push(replaceNode(source, plugins, previous, edited)) + edits.push(replaceNode(source, section, previous, edited)) } } } diff --git a/src/main/hermes/hermes-config-yaml.ts b/src/main/hermes/hermes-config-yaml.ts index 22d93e59723..639fdd09153 100644 --- a/src/main/hermes/hermes-config-yaml.ts +++ b/src/main/hermes/hermes-config-yaml.ts @@ -1,7 +1,7 @@ import { isDeepStrictEqual } from 'node:util' import { parse } from 'yaml' -import { HERMES_PLUGIN_NAME } from './hermes-managed-plugin-source' +import { HERMES_CLIENT_ENV_PASSTHROUGH, HERMES_PLUGIN_NAME } from './hermes-managed-plugin-source' import { updateHermesPluginDocument } from './hermes-config-document' export type HermesConfig = Record @@ -76,6 +76,35 @@ export function disablePlugin(config: HermesConfig): HermesConfig { return next } +function updatePassthrough( + config: HermesConfig, + update: (names: string[]) => string[] +): HermesConfig { + const terminal = config.terminal === undefined ? {} : config.terminal + // Hermes ignores a malformed section; leave it for the user rather than fail the plugin install. + const names = isRecord(terminal) ? asStringArray(terminal.client_env_passthrough) : null + if (names === null) { + return config + } + const next = update(names) + return isDeepStrictEqual(names, next) + ? config + : { ...config, terminal: { ...terminal, client_env_passthrough: next } } +} + +export function installOrcaStatusConfig(config: HermesConfig): HermesConfig { + return updatePassthrough(enablePlugin(config), (names) => [ + ...names, + ...HERMES_CLIENT_ENV_PASSTHROUGH.filter((name) => !names.includes(name)) + ]) +} + +export function removeOrcaStatusConfig(config: HermesConfig): HermesConfig { + return updatePassthrough(disablePlugin(config), (names) => + names.filter((name) => !HERMES_CLIENT_ENV_PASSTHROUGH.includes(name)) + ) +} + export function updateConfigContent( content: string | null, updater: (config: HermesConfig) => HermesConfig @@ -88,7 +117,25 @@ export function updateConfigContent( if (isDeepStrictEqual(parsed.config, next)) { return { content: content ?? '' } } - return updateHermesPluginDocument(content ?? '', next) + return updateHermesPluginDocument(content ?? '', parsed.config, next) +} + +// Why: the passthrough is best-effort; a terminal section we can't safely edit must not block the plugin. +function updateWithPluginFallback( + content: string | null, + updater: (config: HermesConfig) => HermesConfig, + pluginOnly: (config: HermesConfig) => HermesConfig +): { content: string | null; detail?: string } { + const next = updateConfigContent(content, updater) + return next.content === null ? updateConfigContent(content, pluginOnly) : next +} + +export function installOrcaStatusContent(content: string | null) { + return updateWithPluginFallback(content, installOrcaStatusConfig, enablePlugin) +} + +export function removeOrcaStatusContent(content: string | null) { + return updateWithPluginFallback(content, removeOrcaStatusConfig, disablePlugin) } export function getConfigEnablement(config: HermesConfig): { diff --git a/src/main/hermes/hermes-managed-plugin-source.ts b/src/main/hermes/hermes-managed-plugin-source.ts index 9008e6dd95d..306351111ca 100644 --- a/src/main/hermes/hermes-managed-plugin-source.ts +++ b/src/main/hermes/hermes-managed-plugin-source.ts @@ -1,3 +1,8 @@ +import { + ORCA_SCRUB_SAFE_LAUNCH_ENV, + ORCA_SCRUB_SAFE_PANE_ENV +} from '../../shared/agent-hook-scrub-safe-env' + export const HERMES_PLUGIN_NAME = 'orca-status' export const HERMES_PLUGIN_MARKER = 'Managed by Orca. Do not edit; changes may be overwritten.' @@ -14,6 +19,17 @@ export const HERMES_EVENTS = [ 'on_session_reset' ] as const +// Hermes' shared gateway binds only these client env names to a session; KEY/TOKEN names never travel. +export const HERMES_CLIENT_ENV_PASSTHROUGH: readonly string[] = [ + ORCA_SCRUB_SAFE_PANE_ENV, + ORCA_SCRUB_SAFE_LAUNCH_ENV, + 'ORCA_TAB_ID', + 'ORCA_WORKTREE_ID', + 'ORCA_AGENT_HOOK_ENDPOINT', + 'ORCA_AGENT_HOOK_ENV', + 'ORCA_AGENT_HOOK_VERSION' +] + export function getPluginManifest(): string { return [ `# ${HERMES_PLUGIN_MARKER}`, @@ -38,6 +54,12 @@ import urllib.error import urllib.request from typing import Any, Callable, Optional +try: + from agent.session_client_env import bound_client_env +except ImportError: + # Hermes before the shared gateway runs each pane's session in that pane's own process. + bound_client_env = None + EVENTS = ${JSON.stringify(HERMES_EVENTS)} # Why: hook args/results can include full tool payloads. Bound traversal before # JSON encoding so status hooks stay best-effort and cheap. @@ -97,8 +119,20 @@ def _jsonable(value: Any, depth: int = 0, budget: Optional[list[int]] = None) -> return _truncate_string(repr(value)) -def _endpoint_env() -> dict[str, str]: - env = dict(os.environ) +def _pane_env() -> Optional[dict[str, str]]: + if bound_client_env is None: + return dict(os.environ) + # The shared gateway's os.environ is whichever pane started it; unbound events have no proven pane. + client_env = bound_client_env() + if client_env is None: + return None + env = dict(client_env) + env.setdefault("ORCA_PANE_KEY", env.get("${ORCA_SCRUB_SAFE_PANE_ENV}", "")) + env.setdefault("ORCA_AGENT_LAUNCH_TOKEN", env.get("${ORCA_SCRUB_SAFE_LAUNCH_ENV}", "")) + return env + + +def _endpoint_env(env: dict[str, str]) -> dict[str, str]: endpoint = env.get("ORCA_AGENT_HOOK_ENDPOINT", "") if endpoint and os.path.isfile(endpoint): try: @@ -118,7 +152,10 @@ def _endpoint_env() -> dict[str, str]: def _post_to_orca(payload: dict[str, Any]) -> None: - env = _endpoint_env() + pane_env = _pane_env() + if pane_env is None: + return + env = _endpoint_env(pane_env) port = env.get("ORCA_AGENT_HOOK_PORT", "") token = env.get("ORCA_AGENT_HOOK_TOKEN", "") pane_key = env.get("ORCA_PANE_KEY", "") diff --git a/src/main/hermes/hook-service.test.ts b/src/main/hermes/hook-service.test.ts index 29922745417..ce6ec3beb08 100644 --- a/src/main/hermes/hook-service.test.ts +++ b/src/main/hermes/hook-service.test.ts @@ -1,12 +1,13 @@ import { createServer } from 'node:http' import { execFile, execFileSync, spawnSync } from 'node:child_process' -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { parse } from 'yaml' import { makePaneKey } from '../../shared/stable-pane-id' +import { HERMES_CLIENT_ENV_PASSTHROUGH } from './hermes-managed-plugin-source' import { HermesHookService, _internals } from './hook-service' const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') @@ -74,6 +75,94 @@ describe('HermesHookService', () => { expect(config.plugins.disabled).toEqual([]) }) + it('passes pane identity through Hermes client env without dropping user entries', () => { + const configPath = join(homeDir, 'config.yaml') + writeFileSync( + configPath, + [ + 'terminal:', + ' backend: local', + ' client_env_passthrough:', + ' - MY_VAR # mine', + ' - ORCA_TAB_ID', + 'plugins:', + ' enabled: [other]', + '' + ].join('\n'), + 'utf-8' + ) + const service = new HermesHookService() + + service.install() + const installed = readFileSync(configPath, 'utf-8') + expect(parse(installed).terminal).toEqual({ + backend: 'local', + client_env_passthrough: [ + 'MY_VAR', + 'ORCA_TAB_ID', + 'ORCA_AGENT_PANE', + 'ORCA_AGENT_LAUNCH', + 'ORCA_WORKTREE_ID', + 'ORCA_AGENT_HOOK_ENDPOINT', + 'ORCA_AGENT_HOOK_ENV', + 'ORCA_AGENT_HOOK_VERSION' + ] + }) + expect(installed).toContain('- MY_VAR # mine') + service.install() + expect(readFileSync(configPath, 'utf-8')).toBe(installed) + + service.remove() + const removed = readFileSync(configPath, 'utf-8') + expect(parse(removed)).toEqual({ + terminal: { backend: 'local', client_env_passthrough: ['MY_VAR'] }, + plugins: { enabled: ['other'] } + }) + expect(removed).toContain('- MY_VAR # mine') + }) + + 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], + ['a scalar terminal', 'terminal: local\n', false], + ['an anchored terminal', 'terminal: &t {backend: local}\nother: *t\n', false], + ['a merge key in terminal', 'base: &b {backend: local}\nterminal:\n <<: *b\n', false], + ['an anchored passthrough', 'terminal:\n client_env_passthrough: &p [A]\nx: *p\n', false] + ])('still installs and removes the plugin beside %s', (_name, input, passesThrough) => { + writeFileSync(join(homeDir, 'config.yaml'), input) + + const service = new HermesHookService() + expect(service.install().state).toBe('installed') + const { plugins, terminal, ...rest } = parse( + readFileSync(join(homeDir, 'config.yaml'), 'utf-8') + ) + const { terminal: original, ...originalRest } = parse(input) + expect(plugins.enabled).toEqual(['orca-status']) + expect(rest).toEqual(originalRest) + expect(terminal).toEqual( + passesThrough + ? { ...original, client_env_passthrough: [...HERMES_CLIENT_ENV_PASSTHROUGH] } + : original + ) + expect(service.remove().state).toBe('not_installed') + }) + + it('adds the passthrough when a rewritten plugin list runs into a closing comment', () => { + const configPath = join(homeDir, 'config.yaml') + writeFileSync( + configPath, + 'plugins:\n disabled:\n - keep\n - orca-status # managed entry\n# End notes\n' + ) + + expect(new HermesHookService().install().state).toBe('installed') + const installed = readFileSync(configPath, 'utf-8') + expect(parse(installed)).toEqual({ + plugins: { disabled: ['keep'], enabled: ['orca-status'] }, + terminal: { client_env_passthrough: expect.arrayContaining(['ORCA_AGENT_PANE']) } + }) + expect(installed.split('# End notes')).toHaveLength(2) + }) + it('refuses malformed plugin lists during install', () => { writeFileSync( join(homeDir, 'config.yaml'), @@ -224,6 +313,126 @@ describe('HermesHookService', () => { }) }) + it('attributes each shared-gateway session to its own pane, not the daemon pane', async () => { + const pythonAvailable = spawnSync('python3', ['--version'], { encoding: 'utf-8' }).status === 0 + if (!pythonAvailable) { + return + } + new HermesHookService().install() + // Mirrors Hermes 0.22's agent.session_client_env: a session's client env is bound per turn. + const hermesRoot = join(homeDir, 'hermes-src') + mkdirSync(join(hermesRoot, 'agent'), { recursive: true }) + writeFileSync(join(hermesRoot, 'agent', '__init__.py'), '') + writeFileSync( + join(hermesRoot, 'agent', 'session_client_env.py'), + [ + 'from contextlib import contextmanager', + 'from contextvars import ContextVar', + "_BOUND = ContextVar('session_client_env', default=None)", + '@contextmanager', + 'def client_env_scope(env):', + ' token = _BOUND.set(env)', + ' try:', + ' yield', + ' finally:', + ' _BOUND.reset(token)', + 'def bound_client_env():', + ' return _BOUND.get()' + ].join('\n') + ) + + const received: Record[] = [] + const server = createServer((req, res) => { + const chunks: Buffer[] = [] + req.on('data', (chunk: Buffer) => chunks.push(chunk)) + req.on('end', () => { + received.push(JSON.parse(Buffer.concat(chunks).toString('utf-8'))) + res.writeHead(204) + res.end() + }) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + try { + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('server did not bind to a TCP port') + } + const endpointPath = join(homeDir, 'endpoint.env') + writeFileSync( + endpointPath, + `ORCA_AGENT_HOOK_PORT=${address.port}\nORCA_AGENT_HOOK_TOKEN=token-1\n` + ) + const initPath = join(homeDir, 'plugins', _internals.HERMES_PLUGIN_NAME, '__init__.py') + const pane = (id: string) => + `{"ORCA_AGENT_PANE": "tab-${id}:pane-${id}", "ORCA_AGENT_LAUNCH": "launch-${id}", "ORCA_TAB_ID": "tab-${id}", "ORCA_WORKTREE_ID": "wt-${id}", "ORCA_AGENT_HOOK_ENDPOINT": ${JSON.stringify(endpointPath)}}` + const script = [ + 'import importlib.util', + 'from agent.session_client_env import client_env_scope', + `spec = importlib.util.spec_from_file_location("orca_status", ${JSON.stringify(initPath)})`, + 'mod = importlib.util.module_from_spec(spec)', + 'spec.loader.exec_module(mod)', + 'class Ctx:', + ' def __init__(self): self.hooks = {}', + ' def register_hook(self, name, callback): self.hooks[name] = callback', + 'ctx = Ctx()', + 'mod.register(ctx)', + `with client_env_scope(${pane('B')}):`, + ' ctx.hooks["pre_llm_call"](session_id="sess-B", user_message="from B")', + `with client_env_scope(${pane('C')}):`, + ' ctx.hooks["pre_tool_call"](session_id="sess-C", tool_name="patch")', + '# Gateway housekeeping runs outside any session, so it has no proven pane.', + 'ctx.hooks["on_session_finalize"](session_id="sess-B", platform="local")', + '# A non-Orca shell attached to the same gateway.', + 'with client_env_scope({"PATH": "/usr/bin"}):', + ' ctx.hooks["pre_llm_call"](session_id="sess-X", user_message="not a pane")' + ].join('\n') + await new Promise((resolve, reject) => { + // The Python hook calls this HTTP server; synchronous execution would deadlock its response. + execFile( + 'python3', + ['-c', script], + { + env: { + ...process.env, + PYTHONPATH: hermesRoot, + ORCA_AGENT_HOOK_PORT: String(address.port), + ORCA_AGENT_HOOK_TOKEN: 'token-1', + ORCA_AGENT_HOOK_ENDPOINT: '', + ORCA_PANE_KEY: 'tab-A:pane-A', + ORCA_AGENT_PANE: 'tab-A:pane-A', + ORCA_AGENT_LAUNCH_TOKEN: 'launch-A', + ORCA_TAB_ID: 'tab-A', + ORCA_WORKTREE_ID: 'wt-A' + }, + encoding: 'utf-8', + timeout: 15_000 + }, + (error) => (error ? reject(error) : resolve()) + ) + }) + } finally { + server.close() + } + + expect(received).toMatchObject([ + { + paneKey: 'tab-B:pane-B', + launchToken: 'launch-B', + tabId: 'tab-B', + worktreeId: 'wt-B', + payload: { hook_event_name: 'pre_llm_call', session_id: 'sess-B' } + }, + { + paneKey: 'tab-C:pane-C', + launchToken: 'launch-C', + tabId: 'tab-C', + worktreeId: 'wt-C', + payload: { hook_event_name: 'pre_tool_call', session_id: 'sess-C' } + } + ]) + expect(received).toHaveLength(2) + }) + it('bounds generated plugin payload normalization before JSON encoding', () => { const pythonAvailable = spawnSync('python3', ['--version'], { encoding: 'utf-8' }).status === 0 if (!pythonAvailable) { diff --git a/src/main/hermes/hook-service.ts b/src/main/hermes/hook-service.ts index d6bd992e23e..8b4e58039a8 100644 --- a/src/main/hermes/hook-service.ts +++ b/src/main/hermes/hook-service.ts @@ -11,7 +11,9 @@ import { disablePlugin, enablePlugin, getConfigEnablement, + installOrcaStatusContent, parseHermesConfig, + removeOrcaStatusContent, updateConfigContent } from './hermes-config-yaml' import { @@ -96,7 +98,7 @@ export class HermesHookService { } } - const next = updateConfigContent(parsed.content, enablePlugin) + const next = installOrcaStatusContent(parsed.content) if (next.content === null) { return { agent: 'hermes', @@ -119,7 +121,7 @@ export class HermesHookService { const remotePluginDir = `${remoteRoot}/.hermes/plugins/${HERMES_PLUGIN_NAME}` try { const existing = await readTextFileRemote(sftp, remoteConfigPath) - const next = updateConfigContent(existing, enablePlugin) + const next = installOrcaStatusContent(existing) if (next.content === null) { return { agent: 'hermes', @@ -164,7 +166,7 @@ export class HermesHookService { detail: `Could not parse Hermes config.yaml: ${parsed.detail}` } } - const next = updateConfigContent(parsed.content, disablePlugin) + const next = removeOrcaStatusContent(parsed.content) if (next.content === null) { return { agent: 'hermes', diff --git a/src/main/ipc/pty-launch-parity.test.ts b/src/main/ipc/pty-launch-parity.test.ts index 5f96ae2a790..73ad615fe4c 100644 --- a/src/main/ipc/pty-launch-parity.test.ts +++ b/src/main/ipc/pty-launch-parity.test.ts @@ -745,6 +745,7 @@ describe('launch facts outside the tables', () => { ORCA_PANE_KEY: `${tabId}:${leafId}`, ORCA_TAB_ID: tabId }) + expect(env.ORCA_AGENT_PANE).toBe(`${tabId}:${leafId}`) // SSH relays skip local env builders }) }) diff --git a/src/main/ipc/pty/spawn-pane-env.ts b/src/main/ipc/pty/spawn-pane-env.ts index acbf82903e6..1f2537d199a 100644 --- a/src/main/ipc/pty/spawn-pane-env.ts +++ b/src/main/ipc/pty/spawn-pane-env.ts @@ -3,6 +3,7 @@ import { buildJcodeRuntimeDirEnv, JCODE_RUNTIME_DIR_ENV_KEY } from '../../../shared/jcode-runtime-dir' +import { applyScrubSafeAgentEnvAliases } from '../../../shared/agent-hook-scrub-safe-env' import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id' import { isValidTerminalTabId } from '../../../shared/terminal-tab-id' @@ -49,6 +50,7 @@ export function stampPtySpawnPaneEnv( delete env.ORCA_TAB_ID delete env.ORCA_WORKTREE_ID delete env.ORCA_AGENT_LAUNCH_TOKEN + applyScrubSafeAgentEnvAliases(env) return } env.ORCA_PANE_KEY = pane.paneKey @@ -66,6 +68,8 @@ export function stampPtySpawnPaneEnv( if (args.jcodeRuntimeDir !== undefined) { env[JCODE_RUNTIME_DIR_ENV_KEY] = args.jcodeRuntimeDir } + // Why: SSH relay spawns skip the local/daemon env builders that otherwise mirror these. + applyScrubSafeAgentEnvAliases(env) } /** Window local providers validate asynchronously; daemon spawns retain their synchronous preparation. */