fix(hermes): attribute shared-gateway events to each session's own pane (#33288)

Hermes 0.22's shared gateway: the status plugin reads pane identity from each session's own client env (scrub-safe aliases via terminal.client_env_passthrough) instead of the gateway's os.environ, so every pane reports its own status. Unsafe-to-edit configs fall back to main's plugin-only edit; SSH panes now get the scrub-safe aliases. Fixes #32709. Supersedes #33240, #33222.

Co-authored-by:  <+@users.noreply.github.com>
Co-authored-by:  <+@users.noreply.github.com>
This commit is contained in:
Neil
2026-10-24 17:07:35 -07:00
committed by GitHub
co-authored by
parent ca3b0fdb02
commit 5a60ac7405
12 changed files with 443 additions and 78 deletions
@@ -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()
+19 -1
View File
@@ -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)
+53 -43
View File
@@ -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.
@@ -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)
})
@@ -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')
+42 -17
View File
@@ -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))
}
}
}
+49 -2
View File
@@ -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<string, unknown>
@@ -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): {
@@ -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", "")
+210 -1
View File
@@ -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<string, unknown>[] = []
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<void>((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<void>((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) {
+5 -3
View File
@@ -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',
+1
View File
@@ -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
})
})
+4
View File
@@ -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. */