mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 19:17:28 +00:00
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:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user