mirror of
https://github.com/stablyai/orca.git
synced 2026-09-24 16:02:41 +00:00
feat(agent-hooks): scope integrations and bound runners
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
# C4 integration implementation result
|
||||
|
||||
## Scope delivered
|
||||
|
||||
This branch establishes the first C4 integration slice on the execution host:
|
||||
|
||||
- Added one `ManagedAgentIntegration` descriptor per existing managed vendor. Install, refresh, remove, async-remove, and status projections now derive from that descriptor, while legacy tuple exports remain compatible for existing callers. The lifecycle loop consumes descriptors, so operation lists cannot silently diverge.
|
||||
- Made Hermes lifecycle operations profile-aware. Explicit `--profile`, `-p`, and `--profile=` launch arguments take precedence over `active_profile`; profile names are validated before path construction. Install/status/remove use the selected profile home, and YAML updates preserve existing top-level and nested comments while retaining atomic unchanged-write behavior.
|
||||
- Added a bounded Codex JSON stdin runner for POSIX launchers. It returns after a complete JSON value even when the caller keeps stdin open, never falls back to an unbounded `cat`, and emits neutral `{}` output on fail-open paths on POSIX and Windows.
|
||||
- Classified oversized listener payloads with `AgentHookRequestTooLargeError`. Main and relay listeners return JSON HTTP 413 with the fixed one-megabyte limit, pause the request before responding, and do not forward the rejected event.
|
||||
- Resolved OMP profile/config roots before `PI_CODING_AGENT_DIR` exists, validated profile names, and propagated effective XDG data/state/cache roots from shell startup or inherited process environment into local PTY and relay-spawned environments.
|
||||
|
||||
## Evidence
|
||||
|
||||
- Focused C4 integration run: 10 files, 97 passed, 2 skipped.
|
||||
- Broader integration run: 13 files, 185 passed, 4 skipped.
|
||||
- Additional hook/transport run: 6 files, 105 passed, 7 skipped.
|
||||
- Final profile/source-scope run: 2 files, 9 passed.
|
||||
- `pnpm tc:node` passed.
|
||||
- `pnpm run check:code-quality:changed` passed with zero new findings, including type-aware, React Doctor, and casting-safety checks.
|
||||
- `pnpm exec oxfmt --check` passed for all changed/new implementation and test files.
|
||||
- `git diff --check` passed.
|
||||
|
||||
Representative coverage includes managed lifecycle projection, stale-script refresh, profile selection and comment preservation, open-stdin Codex execution, outside-Orca fail-open output, oversized relay requests, listener size bounds, OMP shell/XDG resolution, and existing Windows/WSL hook command contracts.
|
||||
|
||||
## Cases not closed by this branch
|
||||
|
||||
These remain intentionally unresolved and are not claimed as fixed:
|
||||
|
||||
- OpenCode V1/V2 loader migration and a verified current vendor adapter were not changed; Auggie has no verified vendor API and remains unimplemented.
|
||||
- Overlay-only agents (`opencode`, `mimo-code`, `pi`, `omp`, and `prime-agent`) are not yet represented in the managed installer/status registry. Their per-launch overlay paths remain outside the descriptor lifecycle.
|
||||
- Artifact version markers, loader acceptance evidence, round-trip delivery health, and a general integration-health state model were not added.
|
||||
- Claude running-session account ownership/hot switching remains pending the execution binding contract. This branch does not select credentials through trust state, restart a live process, or claim a credential change succeeded.
|
||||
- Remote Hermes profile launch plumbing needs an execution-host launch context from the remote PTY path; the local/profile filesystem resolver is implemented and tested.
|
||||
- Direct executable-argv/fish reproductions for STA-5230 and STA-3936, and historical Kimi/OMP/Prime behavior, need additional vendor/runtime evidence.
|
||||
- Rerouted completion, readiness, interaction, and publication cases remain with their owning batches.
|
||||
|
||||
## Required sibling integration
|
||||
|
||||
- C5 must publish the run/attachment binding contract so integration scope and account roots can be resolved per execution without a second identity or reservation mechanism.
|
||||
- C10 must consume that binding for launch membership/adoption; C4’s descriptors must not become a competing launch registry.
|
||||
- C1 provides the canonical turn reducer; C2 consumes it for provider recovery. C4 adapters must publish normalized provider facts into that reducer rather than adjudicating completion independently.
|
||||
- C3 owns readiness and prompt-delivery evidence; C6 owns exact-attachment execution evidence; C7 owns host composition, remote publication, and replica cutover. Remote status must not be inferred from this branch’s local filesystem reads.
|
||||
|
||||
## Judgments
|
||||
|
||||
- **Architecture fit:** The descriptor projection removes the fragmented local lifecycle ownership and keeps vendor-specific operations behind one host-side contract. Profile and environment resolution now follow launch scope. The full host registry, overlay integration, artifact provenance, and account-binding architecture are still pending the contracts above.
|
||||
- **Functional correctness:** The implemented slices are covered by executable tests and the stated validation gates. They handle bounded input, fail-open output, profile-safe paths, comment-preserving updates, and local/relay OMP environment propagation. Unsupported vendor/API and account-transition behavior remains unverified.
|
||||
- **Private precedent limitations:** The implementation matches the useful mechanism of a single descriptor-driven lifecycle and scoped, atomic materialization. It does not yet match a complete verified-integration health model, vendor-owned transport migration, or host-authoritative remote registry; those are recorded as deviations rather than inferred from passing tests.
|
||||
- **Validation:** All listed checks passed on the current branch. Electron/mobile UI validation was not applicable; no app window was launched.
|
||||
@@ -7,7 +7,8 @@ import { _internals as codexInternals } from '../codex/hook-service'
|
||||
import { buildPosixHookSpoolLines } from './hook-stdin-contract'
|
||||
|
||||
/** Managed hooks are installed into the user's agent config, so they also run when the
|
||||
* agent is launched from a plain terminal. There they must be inert and silent. */
|
||||
* agent is launched from a plain terminal. There they must be inert and return the
|
||||
* vendor-required neutral JSON response without creating Orca state. */
|
||||
function runHook(dir: string, extraEnv: NodeJS.ProcessEnv = {}) {
|
||||
const script = join(dir, 'codex-hook.sh')
|
||||
writeFileSync(script, codexInternals.getManagedScript('posix'))
|
||||
@@ -31,7 +32,7 @@ describe('managed hook outside an Orca terminal', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-outside-'))
|
||||
const res = runHook(dir)
|
||||
expect(res.status).toBe(0)
|
||||
expect(res.stdout).toBe('')
|
||||
expect(res.stdout).toBe('{}\n')
|
||||
expect(res.stderr).toBe('')
|
||||
expect(readdirSync(dir)).toEqual(['codex-hook.sh'])
|
||||
})
|
||||
@@ -40,7 +41,7 @@ describe('managed hook outside an Orca terminal', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-outside-partial-'))
|
||||
const res = runHook(dir, { ORCA_PANE_KEY: 'tab:0', ORCA_TAB_ID: 'tab' })
|
||||
expect(res.status).toBe(0)
|
||||
expect(res.stdout).toBe('')
|
||||
expect(res.stdout).toBe('{}\n')
|
||||
expect(res.stderr).toBe('')
|
||||
expect(readdirSync(dir)).toEqual(['codex-hook.sh'])
|
||||
})
|
||||
@@ -52,7 +53,7 @@ describe('managed hook outside an Orca terminal', () => {
|
||||
ORCA_PANE_KEY: 'tab:0'
|
||||
})
|
||||
expect(res.status).toBe(0)
|
||||
expect(res.stdout).toBe('')
|
||||
expect(res.stdout).toBe('{}\n')
|
||||
expect(res.stderr).toBe('')
|
||||
// a stale env var must not create a spool tree for an Orca that is not installed here
|
||||
expect(readdirSync(dir)).toEqual(['codex-hook.sh'])
|
||||
@@ -64,7 +65,7 @@ describe('managed hook outside an Orca terminal', () => {
|
||||
writeFileSync(endpoint, 'ORCA_AGENT_HOOK_PORT=9\nORCA_AGENT_HOOK_TOKEN=stale\n')
|
||||
const res = runHook(dir, { ORCA_AGENT_HOOK_ENDPOINT: endpoint })
|
||||
expect(res.status).toBe(0)
|
||||
expect(res.stdout).toBe('')
|
||||
expect(res.stdout).toBe('{}\n')
|
||||
expect(res.stderr).toBe('')
|
||||
expect(readdirSync(dir).sort()).toEqual(['codex-hook.sh', 'endpoint.env'])
|
||||
})
|
||||
|
||||
@@ -89,6 +89,12 @@ const POSIX_HOOK_JSON_STDIN_HOME_GUARD = '{ [ -d "${HOME:-}" ] || unset HOME; }'
|
||||
// that only ships `python` does not drop straight to the `cat` hang.
|
||||
export const POSIX_HOOK_JSON_STDIN_READER = `${POSIX_HOOK_JSON_STDIN_HOME_GUARD}; ${jsonStdinInterpreter('python3')} || ${jsonStdinInterpreter('python')} || ${POSIX_HOOK_STDIN_READER}`
|
||||
|
||||
// Hook callers that keep stdin open (notably Codex's macOS bridge) must never
|
||||
// fall back to an unbounded `cat`. Missing Python is a bounded no-op: the
|
||||
// vendor can continue without an Orca observation, while its TUI remains
|
||||
// responsive and the event can still be recovered by the provider adapter.
|
||||
export const POSIX_HOOK_BOUNDED_JSON_STDIN_READER = `${POSIX_HOOK_JSON_STDIN_HOME_GUARD}; ${jsonStdinInterpreter('python3')} || ${jsonStdinInterpreter('python')} || { exit 0; }`
|
||||
|
||||
/** Optional reader override for an agent whose caller keeps stdin open after the payload.
|
||||
* `prelude` must be emitted before the capture line; keep them together. */
|
||||
export type PosixHookStdinReader = {
|
||||
@@ -101,6 +107,11 @@ export const POSIX_HOOK_JSON_STDIN: PosixHookStdinReader = {
|
||||
prelude: POSIX_HOOK_JSON_STDIN_PRELUDE
|
||||
}
|
||||
|
||||
export const POSIX_HOOK_BOUNDED_JSON_STDIN: PosixHookStdinReader = {
|
||||
reader: POSIX_HOOK_BOUNDED_JSON_STDIN_READER,
|
||||
prelude: POSIX_HOOK_JSON_STDIN_PRELUDE
|
||||
}
|
||||
|
||||
// Why: every POSIX hook must own stdin before any no-op exit; sharing this
|
||||
// prelude prevents agent templates from inventing different drain semantics.
|
||||
export function buildPosixHookPayloadCapture(
|
||||
|
||||
@@ -24,6 +24,24 @@ vi.mock('../claude/claude-session-end-hook-capability', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('./managed-agent-hook-registry', () => ({
|
||||
MANAGED_AGENT_INTEGRATIONS: [
|
||||
{
|
||||
agent: 'claude',
|
||||
install: mocks.installClaude,
|
||||
refreshManagedScripts: mocks.refreshClaude,
|
||||
remove: mocks.removeClaude,
|
||||
removeAsync: mocks.removeClaudeAsync,
|
||||
getStatus: mocks.statusClaude
|
||||
},
|
||||
{
|
||||
agent: 'codex',
|
||||
install: mocks.installCodex,
|
||||
refreshManagedScripts: mocks.refreshCodex,
|
||||
remove: mocks.removeCodex,
|
||||
removeAsync: mocks.removeCodexAsync,
|
||||
getStatus: mocks.statusCodex
|
||||
}
|
||||
],
|
||||
MANAGED_AGENT_HOOK_INSTALLERS: [
|
||||
['claude', mocks.installClaude],
|
||||
['codex', mocks.installCodex]
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
MANAGED_AGENT_HOOK_REMOVERS,
|
||||
MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS,
|
||||
MANAGED_AGENT_HOOK_STATUS_READERS,
|
||||
type ManagedAgentHookInstaller,
|
||||
MANAGED_AGENT_INTEGRATIONS,
|
||||
type ManagedAgentIntegration,
|
||||
type ManagedAgentHookInstallOptions
|
||||
} from './managed-agent-hook-registry'
|
||||
|
||||
@@ -103,20 +104,42 @@ function skippedStatus(
|
||||
}
|
||||
}
|
||||
|
||||
function selectedInstallers(options: InstallOptions): readonly ManagedAgentHookInstaller[] {
|
||||
function managedIntegrations(): readonly ManagedAgentIntegration[] {
|
||||
if (Array.isArray(MANAGED_AGENT_INTEGRATIONS)) {
|
||||
return MANAGED_AGENT_INTEGRATIONS
|
||||
}
|
||||
|
||||
// Compatibility for embedders that mocked the tuple projections before the
|
||||
// descriptor registry existed. Production always takes the branch above.
|
||||
const refreshers = new Map(MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS)
|
||||
const removers = new Map(MANAGED_AGENT_HOOK_REMOVERS)
|
||||
const asyncRemovers = new Map(MANAGED_AGENT_HOOK_ASYNC_REMOVERS)
|
||||
const readers = new Map(MANAGED_AGENT_HOOK_STATUS_READERS)
|
||||
return MANAGED_AGENT_HOOK_INSTALLERS.map(([agent, install]) => ({
|
||||
agent,
|
||||
install,
|
||||
refreshManagedScripts: refreshers.get(agent),
|
||||
remove: removers.get(agent) ?? (() => errorStatus(agent, 'remove is unavailable')),
|
||||
removeAsync: asyncRemovers.get(agent),
|
||||
getStatus: readers.get(agent) ?? (() => errorStatus(agent, 'status is unavailable'))
|
||||
}))
|
||||
}
|
||||
|
||||
function selectedIntegrations(options: InstallOptions): readonly ManagedAgentIntegration[] {
|
||||
const integrations = managedIntegrations()
|
||||
if (!options.agents) {
|
||||
return MANAGED_AGENT_HOOK_INSTALLERS
|
||||
return integrations
|
||||
}
|
||||
const allowed = new Set(options.agents)
|
||||
return MANAGED_AGENT_HOOK_INSTALLERS.filter(([agent]) => allowed.has(agent))
|
||||
return integrations.filter(({ agent }) => allowed.has(agent))
|
||||
}
|
||||
|
||||
async function runInstaller(
|
||||
entry: ManagedAgentHookInstaller,
|
||||
entry: ManagedAgentIntegration,
|
||||
onInstallError: InstallOptions['onInstallError'],
|
||||
options: ManagedAgentHookInstallOptions
|
||||
): Promise<AgentHookInstallStatus> {
|
||||
const [agent, install] = entry
|
||||
const { agent, install } = entry
|
||||
try {
|
||||
return await install(options)
|
||||
} catch (error) {
|
||||
@@ -136,8 +159,8 @@ async function runInstaller(
|
||||
// current before any gating; creating new ones remains install()'s presence-gated job.
|
||||
async function refreshExistingManagedScripts(options: InstallOptions): Promise<void> {
|
||||
const allowed = options.agents ? new Set(options.agents) : null
|
||||
for (const [agent, refresh] of MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS) {
|
||||
if (allowed !== null && !allowed.has(agent)) {
|
||||
for (const { agent, refreshManagedScripts: refresh } of managedIntegrations()) {
|
||||
if (!refresh || (allowed !== null && !allowed.has(agent))) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
@@ -153,10 +176,10 @@ export async function installManagedAgentHooks(
|
||||
options: InstallOptions = {}
|
||||
): Promise<AgentHookInstallStatus[]> {
|
||||
await refreshExistingManagedScripts(options)
|
||||
const installers = selectedInstallers(options)
|
||||
const installers = selectedIntegrations(options)
|
||||
const disabled = new Set(normalizeDisabledTuiAgents(settings?.disabledTuiAgents))
|
||||
const enabledInstallers = installers.filter(([agent]) => !disabled.has(agent))
|
||||
const targets = enabledInstallers.flatMap(([agent]) => {
|
||||
const enabledInstallers = installers.filter(({ agent }) => !disabled.has(agent))
|
||||
const targets = enabledInstallers.flatMap(({ agent }) => {
|
||||
const target = getManagedAgentHookTarget(agent)
|
||||
return target ? [target] : []
|
||||
})
|
||||
@@ -167,7 +190,7 @@ export async function installManagedAgentHooks(
|
||||
})
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
return installers.map(([agent]) =>
|
||||
return installers.map(({ agent }) =>
|
||||
disabled.has(agent)
|
||||
? skippedStatus(agent, 'agent_disabled', 'Agent is disabled in Settings.')
|
||||
: skippedStatus(agent, 'cli_presence_unknown', detail)
|
||||
@@ -176,7 +199,7 @@ export async function installManagedAgentHooks(
|
||||
|
||||
const results: AgentHookInstallStatus[] = []
|
||||
for (const entry of installers) {
|
||||
const [agent] = entry
|
||||
const { agent } = entry
|
||||
if (disabled.has(agent)) {
|
||||
results.push(skippedStatus(agent, 'agent_disabled', 'Agent is disabled in Settings.'))
|
||||
continue
|
||||
@@ -221,7 +244,7 @@ export async function removeManagedAgentHooks(
|
||||
): Promise<AgentHookInstallStatus[]> {
|
||||
const allowed = options.agents ? new Set(options.agents) : null
|
||||
const results: AgentHookInstallStatus[] = []
|
||||
for (const [agent, remove] of MANAGED_AGENT_HOOK_REMOVERS) {
|
||||
for (const { agent, remove } of managedIntegrations()) {
|
||||
if (allowed !== null && !allowed.has(agent)) {
|
||||
continue
|
||||
}
|
||||
@@ -239,20 +262,26 @@ export async function removeManagedAgentHooksAsync(
|
||||
): Promise<AgentHookInstallStatus[]> {
|
||||
const allowed = options.agents ? new Set(options.agents) : null
|
||||
return await Promise.all(
|
||||
MANAGED_AGENT_HOOK_ASYNC_REMOVERS.filter(
|
||||
([agent]) => allowed === null || allowed.has(agent)
|
||||
).map(async ([agent, remove]) => {
|
||||
try {
|
||||
return await remove()
|
||||
} catch (error) {
|
||||
return errorStatus(agent, error)
|
||||
}
|
||||
})
|
||||
managedIntegrations()
|
||||
.filter(
|
||||
({ agent, removeAsync }) =>
|
||||
removeAsync !== undefined && (allowed === null || allowed.has(agent))
|
||||
)
|
||||
.map(async ({ agent, removeAsync }) => {
|
||||
if (!removeAsync) {
|
||||
return errorStatus(agent, 'remove is unavailable')
|
||||
}
|
||||
try {
|
||||
return await removeAsync()
|
||||
} catch (error) {
|
||||
return errorStatus(agent, error)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function getManagedAgentHookStatuses(): AgentHookInstallStatus[] {
|
||||
return MANAGED_AGENT_HOOK_STATUS_READERS.map(([agent, getStatus]) => {
|
||||
return managedIntegrations().map(({ agent, getStatus }) => {
|
||||
try {
|
||||
return getStatus()
|
||||
} catch (error) {
|
||||
|
||||
@@ -36,78 +36,151 @@ export type ManagedAgentHookAsyncRemover = readonly [
|
||||
]
|
||||
export type ManagedAgentHookStatusReader = readonly [HookInstallAgent, () => AgentHookInstallStatus]
|
||||
|
||||
export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] = [
|
||||
['claude', (options) => claudeHookService.install({ claudeVersion: options?.cliVersion })],
|
||||
['openclaude', () => openClaudeHookService.install()],
|
||||
['codex', () => codexHookService.install()],
|
||||
['gemini', () => geminiHookService.install()],
|
||||
['antigravity', () => antigravityHookService.install()],
|
||||
['amp', () => ampHookService.install()],
|
||||
['cursor', () => cursorHookService.install()],
|
||||
['droid', () => droidHookService.install()],
|
||||
['command-code', () => commandCodeHookService.install()],
|
||||
['grok', (options) => grokHookService.install(options)],
|
||||
['copilot', () => copilotHookService.install()],
|
||||
['hermes', () => hermesHookService.install()],
|
||||
['devin', () => devinHookService.install()],
|
||||
['kimi', () => kimiHookService.install()]
|
||||
/**
|
||||
* The complete lifecycle for one vendor integration. The tuple exports below
|
||||
* remain as a compatibility projection for older callers, but new lifecycle
|
||||
* code should consume this descriptor so install, refresh, remove and status
|
||||
* cannot silently drift into different vendor lists.
|
||||
*/
|
||||
export type ManagedAgentIntegration = {
|
||||
readonly agent: HookInstallAgent
|
||||
readonly install: (
|
||||
options?: ManagedAgentHookInstallOptions
|
||||
) => AgentHookInstallStatus | Promise<AgentHookInstallStatus>
|
||||
readonly refreshManagedScripts?: () => Promise<void>
|
||||
readonly remove: () => AgentHookInstallStatus | Promise<AgentHookInstallStatus>
|
||||
readonly removeAsync?: () => Promise<AgentHookInstallStatus>
|
||||
readonly getStatus: () => AgentHookInstallStatus
|
||||
}
|
||||
|
||||
export const MANAGED_AGENT_INTEGRATIONS: readonly ManagedAgentIntegration[] = [
|
||||
{
|
||||
agent: 'claude',
|
||||
install: (options) => claudeHookService.install({ claudeVersion: options?.cliVersion }),
|
||||
refreshManagedScripts: () => claudeHookService.refreshManagedScripts(),
|
||||
remove: () => claudeHookService.remove(),
|
||||
getStatus: () => claudeHookService.getStatus()
|
||||
},
|
||||
{
|
||||
agent: 'openclaude',
|
||||
install: () => openClaudeHookService.install(),
|
||||
refreshManagedScripts: () => openClaudeHookService.refreshManagedScripts(),
|
||||
remove: () => openClaudeHookService.remove(),
|
||||
getStatus: () => openClaudeHookService.getStatus()
|
||||
},
|
||||
{
|
||||
agent: 'codex',
|
||||
install: () => codexHookService.install(),
|
||||
refreshManagedScripts: () => codexHookService.refreshManagedScripts(),
|
||||
remove: () => codexHookService.remove(),
|
||||
getStatus: () => codexHookService.getStatus()
|
||||
},
|
||||
{
|
||||
agent: 'gemini',
|
||||
install: () => geminiHookService.install(),
|
||||
refreshManagedScripts: () => geminiHookService.refreshManagedScripts(),
|
||||
remove: () => geminiHookService.remove(),
|
||||
getStatus: () => geminiHookService.getStatus()
|
||||
},
|
||||
{
|
||||
agent: 'antigravity',
|
||||
install: () => antigravityHookService.install(),
|
||||
refreshManagedScripts: () => antigravityHookService.refreshManagedScripts(),
|
||||
remove: () => antigravityHookService.remove(),
|
||||
getStatus: () => antigravityHookService.getStatus()
|
||||
},
|
||||
{
|
||||
agent: 'amp',
|
||||
install: () => ampHookService.install(),
|
||||
remove: () => ampHookService.remove(),
|
||||
getStatus: () => ampHookService.getStatus()
|
||||
},
|
||||
{
|
||||
agent: 'cursor',
|
||||
install: () => cursorHookService.install(),
|
||||
refreshManagedScripts: () => cursorHookService.refreshManagedScripts(),
|
||||
remove: () => cursorHookService.remove(),
|
||||
getStatus: () => cursorHookService.getStatus()
|
||||
},
|
||||
{
|
||||
agent: 'droid',
|
||||
install: () => droidHookService.install(),
|
||||
refreshManagedScripts: () => droidHookService.refreshManagedScripts(),
|
||||
remove: () => droidHookService.remove(),
|
||||
getStatus: () => droidHookService.getStatus()
|
||||
},
|
||||
{
|
||||
agent: 'command-code',
|
||||
install: () => commandCodeHookService.install(),
|
||||
refreshManagedScripts: () => commandCodeHookService.refreshManagedScripts(),
|
||||
remove: () => commandCodeHookService.remove(),
|
||||
getStatus: () => commandCodeHookService.getStatus()
|
||||
},
|
||||
{
|
||||
agent: 'grok',
|
||||
install: (options) => grokHookService.install(options),
|
||||
refreshManagedScripts: () => grokHookService.refreshManagedScripts(),
|
||||
remove: () => grokHookService.remove(),
|
||||
removeAsync: () => grokHookService.removeAsync(),
|
||||
getStatus: () => grokHookService.getStatus()
|
||||
},
|
||||
{
|
||||
agent: 'copilot',
|
||||
install: () => copilotHookService.install(),
|
||||
refreshManagedScripts: () => copilotHookService.refreshManagedScripts(),
|
||||
remove: () => copilotHookService.remove(),
|
||||
getStatus: () => copilotHookService.getStatus()
|
||||
},
|
||||
{
|
||||
agent: 'hermes',
|
||||
install: () => hermesHookService.install(),
|
||||
remove: () => hermesHookService.remove(),
|
||||
getStatus: () => hermesHookService.getStatus()
|
||||
},
|
||||
{
|
||||
agent: 'devin',
|
||||
install: () => devinHookService.install(),
|
||||
refreshManagedScripts: () => devinHookService.refreshManagedScripts(),
|
||||
remove: () => devinHookService.remove(),
|
||||
getStatus: () => devinHookService.getStatus()
|
||||
},
|
||||
{
|
||||
agent: 'kimi',
|
||||
install: () => kimiHookService.install(),
|
||||
refreshManagedScripts: () => kimiHookService.refreshManagedScripts(),
|
||||
remove: () => kimiHookService.remove(),
|
||||
getStatus: () => kimiHookService.getStatus()
|
||||
}
|
||||
]
|
||||
|
||||
// Compatibility projections for the existing IPC and remote installer tests.
|
||||
// They are derived from the descriptor and therefore cannot acquire a vendor
|
||||
// independently of the lifecycle entry above.
|
||||
export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] =
|
||||
MANAGED_AGENT_INTEGRATIONS.map((integration) => [integration.agent, integration.install] as const)
|
||||
|
||||
// Why: covers the shared launcher/statusline scripts under ~/.orca/agent-hooks — the files a
|
||||
// user-wide agent config keeps invoking after the CLI falls off PATH. Amp and Hermes write
|
||||
// provider-native plugin code into their own config dirs with their own install lifecycles,
|
||||
// not shared launchers, so they are deliberately absent. Enforced by the coverage test in
|
||||
// managed-hook-script-refresh.test.ts: a new installer that writes a launcher without adding
|
||||
// a refresher here fails that test.
|
||||
export const MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: readonly ManagedAgentHookScriptRefresher[] = [
|
||||
['claude', () => claudeHookService.refreshManagedScripts()],
|
||||
['openclaude', () => openClaudeHookService.refreshManagedScripts()],
|
||||
['codex', () => codexHookService.refreshManagedScripts()],
|
||||
['gemini', () => geminiHookService.refreshManagedScripts()],
|
||||
['antigravity', () => antigravityHookService.refreshManagedScripts()],
|
||||
['cursor', () => cursorHookService.refreshManagedScripts()],
|
||||
['droid', () => droidHookService.refreshManagedScripts()],
|
||||
['command-code', () => commandCodeHookService.refreshManagedScripts()],
|
||||
['grok', () => grokHookService.refreshManagedScripts()],
|
||||
['copilot', () => copilotHookService.refreshManagedScripts()],
|
||||
['devin', () => devinHookService.refreshManagedScripts()],
|
||||
['kimi', () => kimiHookService.refreshManagedScripts()]
|
||||
]
|
||||
export const MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: readonly ManagedAgentHookScriptRefresher[] =
|
||||
MANAGED_AGENT_INTEGRATIONS.flatMap((integration) =>
|
||||
integration.refreshManagedScripts
|
||||
? ([[integration.agent, integration.refreshManagedScripts]] as const)
|
||||
: []
|
||||
)
|
||||
|
||||
export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [
|
||||
['claude', () => claudeHookService.remove()],
|
||||
['openclaude', () => openClaudeHookService.remove()],
|
||||
['codex', () => codexHookService.remove()],
|
||||
['gemini', () => geminiHookService.remove()],
|
||||
['antigravity', () => antigravityHookService.remove()],
|
||||
['amp', () => ampHookService.remove()],
|
||||
['cursor', () => cursorHookService.remove()],
|
||||
['droid', () => droidHookService.remove()],
|
||||
['command-code', () => commandCodeHookService.remove()],
|
||||
['grok', () => grokHookService.remove()],
|
||||
['copilot', () => copilotHookService.remove()],
|
||||
['hermes', () => hermesHookService.remove()],
|
||||
['devin', () => devinHookService.remove()],
|
||||
['kimi', () => kimiHookService.remove()]
|
||||
]
|
||||
export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] =
|
||||
MANAGED_AGENT_INTEGRATIONS.map((integration) => [integration.agent, integration.remove] as const)
|
||||
|
||||
export const MANAGED_AGENT_HOOK_ASYNC_REMOVERS: readonly ManagedAgentHookAsyncRemover[] = [
|
||||
['grok', () => grokHookService.removeAsync()]
|
||||
]
|
||||
export const MANAGED_AGENT_HOOK_ASYNC_REMOVERS: readonly ManagedAgentHookAsyncRemover[] =
|
||||
MANAGED_AGENT_INTEGRATIONS.flatMap((integration) =>
|
||||
integration.removeAsync ? ([[integration.agent, integration.removeAsync]] as const) : []
|
||||
)
|
||||
|
||||
export const MANAGED_AGENT_HOOK_STATUS_READERS: readonly ManagedAgentHookStatusReader[] = [
|
||||
['claude', () => claudeHookService.getStatus()],
|
||||
['openclaude', () => openClaudeHookService.getStatus()],
|
||||
['codex', () => codexHookService.getStatus()],
|
||||
['gemini', () => geminiHookService.getStatus()],
|
||||
['antigravity', () => antigravityHookService.getStatus()],
|
||||
['amp', () => ampHookService.getStatus()],
|
||||
['cursor', () => cursorHookService.getStatus()],
|
||||
['droid', () => droidHookService.getStatus()],
|
||||
['grok', () => grokHookService.getStatus()],
|
||||
['command-code', () => commandCodeHookService.getStatus()],
|
||||
['copilot', () => copilotHookService.getStatus()],
|
||||
['hermes', () => hermesHookService.getStatus()],
|
||||
['devin', () => devinHookService.getStatus()],
|
||||
['kimi', () => kimiHookService.getStatus()]
|
||||
]
|
||||
export const MANAGED_AGENT_HOOK_STATUS_READERS: readonly ManagedAgentHookStatusReader[] =
|
||||
MANAGED_AGENT_INTEGRATIONS.map(
|
||||
(integration) => [integration.agent, integration.getStatus] as const
|
||||
)
|
||||
|
||||
@@ -66,6 +66,7 @@ import { wrapPosixHookCommand, wrapWindowsHookCommand } from './installer-utils'
|
||||
import {
|
||||
POSIX_HOOK_JSON_STDIN_PRELUDE,
|
||||
POSIX_HOOK_JSON_STDIN_READER,
|
||||
POSIX_HOOK_BOUNDED_JSON_STDIN_READER,
|
||||
POSIX_HOOK_STDIN_READER,
|
||||
WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD
|
||||
} from './hook-stdin-contract'
|
||||
@@ -565,14 +566,18 @@ describe.skipIf(process.platform === 'win32')('managed hook stdin lifecycle', ()
|
||||
for (const [agent, script] of scripts) {
|
||||
const captureIndex = Math.max(
|
||||
script.indexOf(`payload=$(${POSIX_HOOK_STDIN_READER})`),
|
||||
script.indexOf(`payload=$(${POSIX_HOOK_JSON_STDIN_READER})`)
|
||||
script.indexOf(`payload=$(${POSIX_HOOK_JSON_STDIN_READER})`),
|
||||
script.indexOf(`payload=$(${POSIX_HOOK_BOUNDED_JSON_STDIN_READER})`)
|
||||
)
|
||||
const firstExitIndex = script.indexOf('exit 0')
|
||||
expect(captureIndex, `${agent} payload capture`).toBeGreaterThanOrEqual(0)
|
||||
expect(firstExitIndex, `${agent} first success exit`).toBeGreaterThan(captureIndex)
|
||||
// Why: the JSON reader dereferences a variable the prelude sets, so a script
|
||||
// that carries the reader must carry its prelude above the capture line.
|
||||
if (script.includes(POSIX_HOOK_JSON_STDIN_READER)) {
|
||||
if (
|
||||
script.includes(POSIX_HOOK_JSON_STDIN_READER) ||
|
||||
script.includes(POSIX_HOOK_BOUNDED_JSON_STDIN_READER)
|
||||
) {
|
||||
const prelude = POSIX_HOOK_JSON_STDIN_PRELUDE.join('\n')
|
||||
expect(script.indexOf(prelude), `${agent} JSON reader prelude`).toBeGreaterThanOrEqual(0)
|
||||
expect(script.indexOf(prelude), `${agent} prelude before capture`).toBeLessThan(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AgentHookServer, _internals } from './server'
|
||||
import { AGENT_STATUS_MAX_FIELD_LENGTH } from '../../shared/agent-status-types'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { HOOK_REQUEST_MAX_BYTES } from '../../shared/agent-hook-listener/request-body'
|
||||
import { buildBody, PANE, LEAF_2, LEAF_3 } from './server.test-fixtures'
|
||||
|
||||
const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({
|
||||
@@ -44,6 +45,30 @@ async function postClaudeHook(
|
||||
}
|
||||
|
||||
describe('AgentHookServer listener replay', () => {
|
||||
it('classifies oversized authenticated hook bodies with 413', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
try {
|
||||
const env = server.buildPtyEnv()
|
||||
const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body: JSON.stringify({ value: 'x'.repeat(HOOK_REQUEST_MAX_BYTES + 1) })
|
||||
})
|
||||
|
||||
expect(response.status).toBe(413)
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
error: 'hook_request_too_large',
|
||||
maxBytes: HOOK_REQUEST_MAX_BYTES
|
||||
})
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts raw JSON hook bodies with base64 metadata headers', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
|
||||
@@ -6,7 +6,11 @@ import {
|
||||
parseClaudeStatusLineBody
|
||||
} from '../../../shared/claude-statusline-rate-limits'
|
||||
import { mergeAgentHookRequestHeaders } from '../../../shared/agent-hook-listener/hook-envelope'
|
||||
import { readRequestBody } from '../../../shared/agent-hook-listener/request-body'
|
||||
import {
|
||||
isAgentHookRequestTooLargeError,
|
||||
readRequestBody,
|
||||
respondWithAgentHookRequestTooLarge
|
||||
} from '../../../shared/agent-hook-listener/request-body'
|
||||
import { resolveHookSource } from '../../../shared/agent-hook-listener/source-routing'
|
||||
import { HOOK_REQUEST_SLOWLORIS_MS } from '../../../shared/agent-hook-listener/listener-limits'
|
||||
import { isHookRequestTruncatedError } from '../../../shared/agent-hook-transport-interference'
|
||||
@@ -122,6 +126,13 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
} catch (error) {
|
||||
if (isAgentHookRequestTooLargeError(error)) {
|
||||
// Return an explicit bounded-transport classification while keeping
|
||||
// the hook fail-open for the agent. Destroy only after the response
|
||||
// is flushed so callers can observe 413 instead of ECONNRESET.
|
||||
respondWithAgentHookRequestTooLarge(res, req)
|
||||
return
|
||||
}
|
||||
// Why (#11217): an authenticated POST whose body dies short of its own Content-Length was cut
|
||||
// by something on the loopback path, not by a bad payload. Fail open as before, but count it —
|
||||
// this is the one failure mode that silently stops status for every runtime at once.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getManagedScript } from './codex-hook-script'
|
||||
|
||||
describe('Codex managed hook runner', () => {
|
||||
it('uses a bounded reader and emits neutral JSON when Orca is unavailable', () => {
|
||||
const script = getManagedScript('posix')
|
||||
expect(script).toContain('orca_hook_json_stdin_py')
|
||||
expect(script).toContain('python3')
|
||||
expect(script).not.toContain('|| { command -p cat')
|
||||
expect(script).toContain("printf '{}\\n'")
|
||||
})
|
||||
|
||||
it('returns after a complete payload even when the caller keeps stdin open', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-codex-hook-script-'))
|
||||
const scriptPath = join(dir, 'codex-hook.sh')
|
||||
writeFileSync(scriptPath, getManagedScript('posix'), 'utf8')
|
||||
chmodSync(scriptPath, 0o755)
|
||||
|
||||
try {
|
||||
const result = await new Promise<{ status: number | null; stdout: string }>(
|
||||
(resolve, reject) => {
|
||||
const child = spawn('/bin/sh', [scriptPath], {
|
||||
env: Object.fromEntries(
|
||||
Object.entries(process.env).filter(([key]) => !key.startsWith('ORCA_'))
|
||||
),
|
||||
stdio: ['pipe', 'pipe', 'ignore']
|
||||
})
|
||||
let stdout = ''
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stdout.on('data', (chunk: string) => {
|
||||
stdout += chunk
|
||||
})
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
reject(new Error('Codex hook did not return with an open stdin'))
|
||||
}, 4_000)
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
})
|
||||
child.on('close', (status) => {
|
||||
clearTimeout(timer)
|
||||
resolve({ status, stdout })
|
||||
})
|
||||
child.stdin.write('{"hook_event_name":"Stop","cwd":"/tmp"}')
|
||||
// Intentionally leave stdin open: Codex's bridge keeps the pipe alive.
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toBe('{}\n')
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
}, 8_000)
|
||||
})
|
||||
@@ -3,7 +3,8 @@ import {
|
||||
buildPosixHookPayloadCapture,
|
||||
buildPosixHookSpoolLines,
|
||||
buildWindowsHookEnvironmentGuardLines,
|
||||
buildWindowsHookStdinDrainEpilogue
|
||||
buildWindowsHookStdinDrainEpilogue,
|
||||
POSIX_HOOK_BOUNDED_JSON_STDIN
|
||||
} from '../agent-hooks/hook-stdin-contract'
|
||||
import { buildWindowsAgentHookCurlPostCommand } from '../agent-hooks/installer-utils'
|
||||
|
||||
@@ -14,6 +15,7 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
'setlocal',
|
||||
// Why: the endpoint file holds this install's live port/token; sourcing it lets a surviving PTY reach the current server (see claude/hook-service.ts).
|
||||
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
|
||||
'echo {}',
|
||||
...buildWindowsHookEnvironmentGuardLines(),
|
||||
buildWindowsAgentHookCurlPostCommand('codex'),
|
||||
'exit /b 0',
|
||||
@@ -24,7 +26,7 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
|
||||
return [
|
||||
'#!/bin/sh',
|
||||
...buildPosixHookPayloadCapture(),
|
||||
...buildPosixHookPayloadCapture('empty-object', POSIX_HOOK_BOUNDED_JSON_STDIN),
|
||||
...buildPosixHookSpoolLines('codex'),
|
||||
// Why: sourcing refreshes PORT/TOKEN/ENV/VERSION from the current Orca so a surviving PTY keeps reporting after a restart (see claude/hook-service.ts).
|
||||
'load_hook_endpoint() {',
|
||||
@@ -55,6 +57,7 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
'fi',
|
||||
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
|
||||
' spool_hook_event',
|
||||
" printf '{}\\n'",
|
||||
' exit 0',
|
||||
'fi',
|
||||
'post_codex_hook() {',
|
||||
@@ -72,18 +75,21 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
' grep -qiE "microsoft|wsl" /proc/sys/kernel/osrelease /proc/version 2>/dev/null',
|
||||
'}',
|
||||
'if post_codex_hook curl >/dev/null 2>&1; then',
|
||||
" printf '{}\\n'",
|
||||
' exit 0',
|
||||
'fi',
|
||||
'if is_wsl_runtime; then',
|
||||
' windows_curl=$(command -v curl.exe 2>/dev/null || true)',
|
||||
' if [ -n "$windows_curl" ] && [ -x "$windows_curl" ]; then',
|
||||
' if post_codex_hook "$windows_curl" 3 5 >/dev/null 2>&1; then',
|
||||
" printf '{}\\n'",
|
||||
' exit 0',
|
||||
' fi',
|
||||
' # post_codex_hook "$windows_curl" 3 5 >/dev/null 2>&1 || true',
|
||||
' fi',
|
||||
'fi',
|
||||
'spool_hook_event',
|
||||
"printf '{}\\n'",
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { parse, stringify } from 'yaml'
|
||||
import { parse, parseDocument, stringify, YAMLMap } from 'yaml'
|
||||
|
||||
import { HERMES_PLUGIN_NAME } from './hermes-managed-plugin-source'
|
||||
|
||||
export type HermesConfig = Record<string, unknown>
|
||||
|
||||
export type ConfigParseResult = { ok: true; config: HermesConfig } | { ok: false; detail: string }
|
||||
export type ConfigParseResult =
|
||||
| { ok: true; config: HermesConfig; source?: string }
|
||||
| { ok: false; detail: string }
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
@@ -41,7 +43,49 @@ export function parseHermesConfig(content: string | null): ConfigParseResult {
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeHermesConfig(config: HermesConfig): string {
|
||||
export function serializeHermesConfig(config: HermesConfig, source?: string): string {
|
||||
if (source !== undefined && source.trim().length > 0) {
|
||||
try {
|
||||
const document = parseDocument(source)
|
||||
if (document.errors.length === 0) {
|
||||
const original = document.toJS()
|
||||
if (isRecord(original)) {
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
// Mutate an existing mapping in place so comments attached to
|
||||
// nested pairs survive the managed update.
|
||||
if (key === 'plugins' && isRecord(value)) {
|
||||
const pluginsNode = document.get(key, true)
|
||||
if (pluginsNode instanceof YAMLMap) {
|
||||
for (const [pluginKey, pluginValue] of Object.entries(value)) {
|
||||
pluginsNode.set(pluginKey, pluginValue)
|
||||
}
|
||||
const pluginKeysToRemove = pluginsNode.items
|
||||
.map((pair) => pair.key)
|
||||
.filter(
|
||||
(pairKey): pairKey is string =>
|
||||
typeof pairKey === 'string' && !(pairKey in value)
|
||||
)
|
||||
for (const pairKey of pluginKeysToRemove) {
|
||||
pluginsNode.delete(pairKey)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
document.set(key, value)
|
||||
}
|
||||
for (const key of Object.keys(original)) {
|
||||
if (!(key in config)) {
|
||||
document.delete(key)
|
||||
}
|
||||
}
|
||||
return document.toString()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall back to a canonical serialization below. Parsing already
|
||||
// succeeded for the normal write path, so this is defensive only.
|
||||
}
|
||||
}
|
||||
return `${stringify(config, { lineWidth: 0 }).trimEnd()}\n`
|
||||
}
|
||||
|
||||
@@ -85,7 +129,7 @@ export function updateConfigContent(
|
||||
if (!parsed.ok) {
|
||||
return { content: null, detail: parsed.detail }
|
||||
}
|
||||
return { content: serializeHermesConfig(updater(parsed.config)) }
|
||||
return { content: serializeHermesConfig(updater(parsed.config), content ?? undefined) }
|
||||
}
|
||||
|
||||
export function getConfigEnablement(config: HermesConfig): {
|
||||
|
||||
@@ -25,12 +25,47 @@ export function getHermesHome(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return explicit ? explicit : join(homedir(), '.hermes')
|
||||
}
|
||||
|
||||
export function getConfigPath(): string {
|
||||
return join(getHermesHome(), 'config.yaml')
|
||||
function isSafeProfileName(value: string): boolean {
|
||||
return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value)
|
||||
}
|
||||
|
||||
export function getPluginDir(): string {
|
||||
return join(getHermesHome(), 'plugins', HERMES_PLUGIN_NAME)
|
||||
function profileFromCommand(command: string | undefined): string | undefined {
|
||||
if (!command) {
|
||||
return undefined
|
||||
}
|
||||
const match = command.match(/(?:^|\s)(?:--profile|-p)(?:=|\s+)(?:"([^"]+)"|'([^']+)'|([^\s]+))/)
|
||||
const profile = match?.[1] ?? match?.[2] ?? match?.[3]
|
||||
return profile && isSafeProfileName(profile) ? profile : undefined
|
||||
}
|
||||
|
||||
function activeHermesProfile(home: string): string | undefined {
|
||||
try {
|
||||
const profile = readFileSync(join(home, 'active_profile'), 'utf8').trim()
|
||||
return profile && isSafeProfileName(profile) ? profile : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the profile Hermes will actually read for one launch. */
|
||||
export function resolveHermesHomeForLaunch(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
launchCommand?: string
|
||||
): string {
|
||||
const root = getHermesHome(env)
|
||||
const profile = profileFromCommand(launchCommand) ?? activeHermesProfile(root)
|
||||
if (!profile || profile === 'default') {
|
||||
return root
|
||||
}
|
||||
return join(root, 'profiles', profile)
|
||||
}
|
||||
|
||||
export function getConfigPath(home = getHermesHome()): string {
|
||||
return join(home, 'config.yaml')
|
||||
}
|
||||
|
||||
export function getPluginDir(home = getHermesHome()): string {
|
||||
return join(home, 'plugins', HERMES_PLUGIN_NAME)
|
||||
}
|
||||
|
||||
function getManifestPath(pluginDir = getPluginDir()): string {
|
||||
@@ -45,13 +80,15 @@ export function readConfigFile(configPath: string): ConfigParseResult {
|
||||
if (!existsSync(configPath)) {
|
||||
return { ok: true, config: {} }
|
||||
}
|
||||
return parseHermesConfig(readFileSync(configPath, 'utf-8'))
|
||||
const source = readFileSync(configPath, 'utf-8')
|
||||
const parsed = parseHermesConfig(source)
|
||||
return parsed.ok ? { ...parsed, source } : parsed
|
||||
}
|
||||
|
||||
export function writeConfigFile(configPath: string, config: HermesConfig): void {
|
||||
export function writeConfigFile(configPath: string, config: HermesConfig, source?: string): void {
|
||||
const dir = dirname(configPath)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
const serialized = serializeHermesConfig(config)
|
||||
const serialized = serializeHermesConfig(config, source)
|
||||
if (existsSync(configPath)) {
|
||||
try {
|
||||
if (readFileSync(configPath, 'utf-8') === serialized) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createServer } from 'node:http'
|
||||
import { execFile, execFileSync, spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, 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'
|
||||
@@ -8,6 +8,7 @@ import { parse } from 'yaml'
|
||||
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { HermesHookService, _internals } from './hook-service'
|
||||
import { resolveHermesHomeForLaunch } from './hermes-home-filesystem'
|
||||
|
||||
const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111')
|
||||
|
||||
@@ -30,6 +31,47 @@ describe('HermesHookService', () => {
|
||||
rmSync(homeDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['--profile coder', 'coder'],
|
||||
['-p coder', 'coder'],
|
||||
['--profile=coder', 'coder'],
|
||||
['hermes --continue --profile "review-team"', 'review-team']
|
||||
])('resolves an explicit Hermes profile from the launch command (%s)', (command, profile) => {
|
||||
expect(resolveHermesHomeForLaunch({ HERMES_HOME: homeDir }, command)).toBe(
|
||||
join(homeDir, 'profiles', profile)
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the active profile when a launch has no explicit profile', () => {
|
||||
writeFileSync(join(homeDir, 'active_profile'), 'coder\n', 'utf8')
|
||||
expect(resolveHermesHomeForLaunch({ HERMES_HOME: homeDir }, 'hermes --tui')).toBe(
|
||||
join(homeDir, 'profiles', 'coder')
|
||||
)
|
||||
})
|
||||
|
||||
it('installs into the selected profile and preserves user comments', () => {
|
||||
const profileHome = join(homeDir, 'profiles', 'coder')
|
||||
const configPath = join(profileHome, 'config.yaml')
|
||||
mkdirSync(profileHome, { recursive: true })
|
||||
writeFileSync(
|
||||
configPath,
|
||||
'# keep this profile comment\nmodel: test-model\nplugins:\n # keep plugin notes\n enabled: []\n',
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const status = new HermesHookService().install({
|
||||
env: { HERMES_HOME: homeDir },
|
||||
launchCommand: 'hermes --profile coder --tui'
|
||||
})
|
||||
|
||||
expect(status.state).toBe('installed')
|
||||
const updated = readFileSync(configPath, 'utf8')
|
||||
expect(updated).toContain('# keep this profile comment')
|
||||
expect(updated).toContain('# keep plugin notes')
|
||||
expect(updated).toContain(_internals.HERMES_PLUGIN_NAME)
|
||||
expect(existsSync(join(homeDir, 'config.yaml'))).toBe(false)
|
||||
})
|
||||
|
||||
it('installs the managed Hermes plugin and enables it in config.yaml', () => {
|
||||
const status = new HermesHookService().install()
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
writeConfigFile,
|
||||
writePluginFiles
|
||||
} from './hermes-home-filesystem'
|
||||
import { resolveHermesHomeForLaunch } from './hermes-home-filesystem'
|
||||
import {
|
||||
HERMES_EVENTS,
|
||||
HERMES_PLUGIN_NAME,
|
||||
@@ -30,8 +31,12 @@ import {
|
||||
getPluginManifest
|
||||
} from './hermes-managed-plugin-source'
|
||||
|
||||
function buildStatus(configPath: string, config: HermesConfig): AgentHookInstallStatus {
|
||||
const pluginFiles = getPluginFilesState()
|
||||
function buildStatus(
|
||||
configPath: string,
|
||||
config: HermesConfig,
|
||||
home: string
|
||||
): AgentHookInstallStatus {
|
||||
const pluginFiles = getPluginFilesState(getPluginDir(home))
|
||||
const enablement = getConfigEnablement(config)
|
||||
const details = [
|
||||
pluginFiles.detail,
|
||||
@@ -68,37 +73,39 @@ function stripTrailingSlash(path: string): string {
|
||||
}
|
||||
|
||||
export class HermesHookService {
|
||||
getStatus(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
getStatus(options?: { env?: NodeJS.ProcessEnv; launchCommand?: string }): AgentHookInstallStatus {
|
||||
const home = resolveHermesHomeForLaunch(options?.env, options?.launchCommand)
|
||||
const configPath = getConfigPath(home)
|
||||
const parsed = readConfigFile(configPath)
|
||||
if (!parsed.ok) {
|
||||
return {
|
||||
agent: 'hermes',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: getPluginFilesState().managed,
|
||||
managedHooksPresent: getPluginFilesState(getPluginDir(home)).managed,
|
||||
detail: `Could not parse Hermes config.yaml: ${parsed.detail}`
|
||||
}
|
||||
}
|
||||
return buildStatus(configPath, parsed.config)
|
||||
return buildStatus(configPath, parsed.config, home)
|
||||
}
|
||||
|
||||
install(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
install(options?: { env?: NodeJS.ProcessEnv; launchCommand?: string }): AgentHookInstallStatus {
|
||||
const home = resolveHermesHomeForLaunch(options?.env, options?.launchCommand)
|
||||
const configPath = getConfigPath(home)
|
||||
const parsed = readConfigFile(configPath)
|
||||
if (!parsed.ok) {
|
||||
return {
|
||||
agent: 'hermes',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: getPluginFilesState().managed,
|
||||
managedHooksPresent: getPluginFilesState(getPluginDir(home)).managed,
|
||||
detail: `Could not parse Hermes config.yaml: ${parsed.detail}`
|
||||
}
|
||||
}
|
||||
|
||||
writePluginFiles()
|
||||
writeConfigFile(configPath, enablePlugin(parsed.config))
|
||||
return this.getStatus()
|
||||
writePluginFiles(getPluginDir(home))
|
||||
writeConfigFile(configPath, enablePlugin(parsed.config), parsed.source)
|
||||
return this.getStatus(options)
|
||||
}
|
||||
|
||||
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
|
||||
@@ -138,24 +145,25 @@ export class HermesHookService {
|
||||
}
|
||||
}
|
||||
|
||||
remove(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
remove(options?: { env?: NodeJS.ProcessEnv; launchCommand?: string }): AgentHookInstallStatus {
|
||||
const home = resolveHermesHomeForLaunch(options?.env, options?.launchCommand)
|
||||
const configPath = getConfigPath(home)
|
||||
const parsed = readConfigFile(configPath)
|
||||
if (!parsed.ok) {
|
||||
return {
|
||||
agent: 'hermes',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: getPluginFilesState().managed,
|
||||
managedHooksPresent: getPluginFilesState(getPluginDir(home)).managed,
|
||||
detail: `Could not parse Hermes config.yaml: ${parsed.detail}`
|
||||
}
|
||||
}
|
||||
const pluginDir = getPluginDir()
|
||||
const pluginDir = getPluginDir(home)
|
||||
if (getPluginFilesState(pluginDir).managed) {
|
||||
rmSync(pluginDir, { recursive: true, force: true })
|
||||
}
|
||||
writeConfigFile(configPath, disablePlugin(parsed.config))
|
||||
return this.getStatus()
|
||||
writeConfigFile(configPath, disablePlugin(parsed.config), parsed.source)
|
||||
return this.getStatus(options)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import { stripInheritedOrcaCodexHomeOverride } from './codex-home'
|
||||
import {
|
||||
clearPiAgentShadowEnv,
|
||||
exposePiManagedExtensionEnv,
|
||||
inheritOmpXdgEnvironment,
|
||||
isMimoLaunchCommand,
|
||||
resolveMimocodeSourceHome,
|
||||
resolveOpenCodeSourceConfigDir,
|
||||
@@ -54,6 +55,14 @@ export function buildPtyHostEnv(
|
||||
const hasLaunchCommand =
|
||||
typeof launchCommandHint === 'string' && launchCommandHint.trim().length > 0
|
||||
|
||||
if (piAgentKind === 'omp' || !hasLaunchCommand) {
|
||||
// OMP uses XDG data/state/cache roots for daemon-owned fragments. Shell
|
||||
// startup exports are not present in a direct daemon spawn, so carry the
|
||||
// effective values into this PTY rather than silently falling back to
|
||||
// ~/.omp.
|
||||
inheritOmpXdgEnvironment(baseEnv)
|
||||
}
|
||||
|
||||
// Why: unattended agents must fail instead of looping on OS credential prompts; user terminals keep normal Git behavior.
|
||||
applyTerminalGitCredentialPromptGuard(baseEnv, {
|
||||
launchCommand: launchCommandHint,
|
||||
@@ -66,7 +75,7 @@ export function buildPtyHostEnv(
|
||||
const preexistingPiAgentDir = resolvePiAgentSourceDir(baseEnv, 'pi')
|
||||
const preexistingOmpAgentDir =
|
||||
piAgentKind === 'omp'
|
||||
? resolvePiAgentSourceDir(baseEnv, 'omp')
|
||||
? resolvePiAgentSourceDir(baseEnv, 'omp', launchCommandHint)
|
||||
: resolveScopedPiAgentSourceDir(baseEnv, 'omp')
|
||||
const preexistingPrimeAgentDir =
|
||||
piAgentKind === 'prime-agent'
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { __resetShellStartupEnvCache } from '../../../pty/shell-startup-env'
|
||||
import { inheritOmpXdgEnvironment, resolvePiAgentSourceDir } from './pi-agent'
|
||||
|
||||
describe('OMP launch scope', () => {
|
||||
let homeDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
homeDir = mkdtempSync(join(tmpdir(), 'orca-omp-scope-'))
|
||||
__resetShellStartupEnvCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(homeDir, { recursive: true, force: true })
|
||||
__resetShellStartupEnvCache()
|
||||
})
|
||||
|
||||
it('resolves the configured OMP profile before PI_CODING_AGENT_DIR exists', () => {
|
||||
const configDir = join(homeDir, 'omp-config')
|
||||
expect(
|
||||
resolvePiAgentSourceDir(
|
||||
{ HOME: homeDir, PI_CONFIG_DIR: configDir },
|
||||
'omp',
|
||||
'omp --profile review-team'
|
||||
)
|
||||
).toBe(join(configDir, 'profiles', 'review-team', 'agent'))
|
||||
})
|
||||
|
||||
it('keeps an explicit non-Orca PI_CODING_AGENT_DIR for the default profile', () => {
|
||||
expect(
|
||||
resolvePiAgentSourceDir(
|
||||
{
|
||||
HOME: homeDir,
|
||||
PI_CONFIG_DIR: join(homeDir, 'config'),
|
||||
PI_CODING_AGENT_DIR: join(homeDir, 'custom-agent')
|
||||
},
|
||||
'omp',
|
||||
'omp'
|
||||
)
|
||||
).toBe(join(homeDir, 'custom-agent'))
|
||||
})
|
||||
|
||||
it('copies XDG data roots exported by the launching shell', () => {
|
||||
const xdgDataHome = join(homeDir, 'xdg-data')
|
||||
const xdgStateHome = join(homeDir, 'xdg-state')
|
||||
const xdgCacheHome = join(homeDir, 'xdg-cache')
|
||||
mkdirSync(xdgDataHome)
|
||||
writeFileSync(
|
||||
join(homeDir, '.zshrc'),
|
||||
[
|
||||
`export XDG_DATA_HOME="$HOME/${xdgDataHome.slice(homeDir.length + 1)}"`,
|
||||
`export XDG_STATE_HOME="$HOME/${xdgStateHome.slice(homeDir.length + 1)}"`,
|
||||
`export XDG_CACHE_HOME="$HOME/${xdgCacheHome.slice(homeDir.length + 1)}"`
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
const env: Record<string, string> = { HOME: homeDir, SHELL: '/bin/zsh' }
|
||||
inheritOmpXdgEnvironment(env)
|
||||
|
||||
expect(env).toMatchObject({
|
||||
XDG_DATA_HOME: xdgDataHome,
|
||||
XDG_STATE_HOME: xdgStateHome,
|
||||
XDG_CACHE_HOME: xdgCacheHome
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
SOURCE_AGENT_DIR_ENV_BY_KIND,
|
||||
type PiAgentKind
|
||||
} from '../../../../shared/pi-agent-kind'
|
||||
import { join } from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
import { readSessionShellStartupEnvVar } from '../../../pty/shell-startup-env'
|
||||
import { AGENT_HOOK_RUNTIME_ENV_KEYS, CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS } from './spawn-env-keys'
|
||||
|
||||
@@ -19,11 +21,30 @@ export function readEnvWithProcessFallback(
|
||||
|
||||
export function resolvePiAgentSourceDir(
|
||||
baseEnv: Record<string, string>,
|
||||
kind: PiAgentKind
|
||||
kind: PiAgentKind,
|
||||
launchCommand?: string
|
||||
): string | undefined {
|
||||
const sourceKey = SOURCE_AGENT_DIR_ENV_BY_KIND[kind]
|
||||
const primaryKey = PRIMARY_AGENT_DIR_ENV_BY_KIND[kind]
|
||||
|
||||
const ompProfile =
|
||||
kind === 'omp'
|
||||
? [
|
||||
readOmpProfileFromCommand(launchCommand),
|
||||
readEnvWithProcessFallback(baseEnv, 'OMP_PROFILE'),
|
||||
readEnvWithProcessFallback(baseEnv, 'PI_PROFILE')
|
||||
].find((candidate) => candidate !== undefined && isSafeOmpProfile(candidate))
|
||||
: undefined
|
||||
|
||||
if (kind === 'omp' && ompProfile) {
|
||||
const configuredRoot =
|
||||
readEnvWithProcessFallback(baseEnv, 'PI_CONFIG_DIR') ??
|
||||
readSessionShellStartupEnvVar('PI_CONFIG_DIR', baseEnv)
|
||||
const configDir =
|
||||
configuredRoot ?? join(readEnvWithProcessFallback(baseEnv, 'HOME') ?? homedir(), '.omp')
|
||||
return join(configDir, 'profiles', ompProfile, 'agent')
|
||||
}
|
||||
|
||||
const sourceDir = readEnvWithProcessFallback(baseEnv, sourceKey)
|
||||
if (sourceDir) {
|
||||
return sourceDir
|
||||
@@ -47,9 +68,47 @@ export function resolvePiAgentSourceDir(
|
||||
return publicDir
|
||||
}
|
||||
|
||||
// OMP keeps its configurable root in PI_CONFIG_DIR; PI_CODING_AGENT_DIR is
|
||||
// only populated after OMP has booted. Resolve the root before launch so the
|
||||
// managed extension is materialized in the same profile the binary will use.
|
||||
if (kind === 'omp') {
|
||||
const configuredRoot =
|
||||
readEnvWithProcessFallback(baseEnv, 'PI_CONFIG_DIR') ??
|
||||
readSessionShellStartupEnvVar('PI_CONFIG_DIR', baseEnv)
|
||||
if (configuredRoot) {
|
||||
return join(configuredRoot, 'agent')
|
||||
}
|
||||
if (launchCommand?.trim()) {
|
||||
return join(readEnvWithProcessFallback(baseEnv, 'HOME') ?? homedir(), '.omp', 'agent')
|
||||
}
|
||||
}
|
||||
|
||||
return readSessionShellStartupEnvVar(primaryKey, baseEnv)
|
||||
}
|
||||
|
||||
function readOmpProfileFromCommand(command: string | undefined): string | undefined {
|
||||
const match = command?.match(/(?:^|\s)--profile(?:=|\s+)(?:"([^"]+)"|'([^']+)'|([^\s]+))/)
|
||||
const profile = match?.[1] ?? match?.[2] ?? match?.[3]
|
||||
return profile && isSafeOmpProfile(profile) ? profile : undefined
|
||||
}
|
||||
|
||||
function isSafeOmpProfile(value: string): boolean {
|
||||
return /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(value)
|
||||
}
|
||||
|
||||
/** Copy XDG roots discovered from the user's actual shell into daemon spawns. */
|
||||
export function inheritOmpXdgEnvironment(baseEnv: Record<string, string>): void {
|
||||
for (const name of ['XDG_DATA_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME'] as const) {
|
||||
if (baseEnv[name] !== undefined) {
|
||||
continue
|
||||
}
|
||||
const value = readSessionShellStartupEnvVar(name, baseEnv) ?? process.env[name]
|
||||
if (value) {
|
||||
baseEnv[name] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveScopedPiAgentSourceDir(
|
||||
baseEnv: Record<string, string>,
|
||||
kind: PiAgentKind
|
||||
|
||||
@@ -349,6 +349,32 @@ describe('RelayAgentHookServer', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('classifies oversized authenticated hook bodies with 413', async () => {
|
||||
const forward = vi.fn()
|
||||
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
|
||||
await server.start()
|
||||
try {
|
||||
const { port, token } = server.getCoordinates()
|
||||
const response = await fetch(`http://127.0.0.1:${port}/hook/claude`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': token
|
||||
},
|
||||
body: JSON.stringify({ value: 'x'.repeat(HOOK_REQUEST_MAX_BYTES + 1) })
|
||||
})
|
||||
|
||||
expect(response.status).toBe(413)
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
error: 'hook_request_too_large',
|
||||
maxBytes: HOOK_REQUEST_MAX_BYTES
|
||||
})
|
||||
expect(forward).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('replays cached payloads on demand', async () => {
|
||||
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
|
||||
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
|
||||
|
||||
@@ -20,7 +20,11 @@ import {
|
||||
import { HOOK_REQUEST_SLOWLORIS_MS } from '../shared/agent-hook-listener/listener-limits'
|
||||
import { normalizeHookPayload } from '../shared/agent-hook-listener'
|
||||
import { mergeAgentHookRequestHeaders } from '../shared/agent-hook-listener/hook-envelope'
|
||||
import { readRequestBody } from '../shared/agent-hook-listener/request-body'
|
||||
import {
|
||||
isAgentHookRequestTooLargeError,
|
||||
readRequestBody,
|
||||
respondWithAgentHookRequestTooLarge
|
||||
} from '../shared/agent-hook-listener/request-body'
|
||||
import { resolveHookSource } from '../shared/agent-hook-listener/source-routing'
|
||||
import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event'
|
||||
import {
|
||||
@@ -29,16 +33,12 @@ import {
|
||||
isHookRequestTruncatedError
|
||||
} from '../shared/agent-hook-transport-interference'
|
||||
import {
|
||||
isAgentHookSource,
|
||||
REMOTE_AGENT_HOOK_ENV,
|
||||
type AgentHookRelayEnvelope,
|
||||
type AgentHookSource
|
||||
} from '../shared/agent-hook-relay'
|
||||
import {
|
||||
buildSpoolHookBody,
|
||||
drainAgentHookSpool,
|
||||
type SpoolRecord
|
||||
} from '../shared/agent-hook-spool'
|
||||
import { drainAgentHookSpool } from '../shared/agent-hook-spool'
|
||||
import { ingestRelayAgentHookSpoolRecord } from './agent-hook-spool-ingest'
|
||||
import { buildRelayHookPtyEnv, defaultEndpointDir } from './agent-hook-endpoint-coordinates'
|
||||
import { buildRelayHookEnvelope, hookBodyEnv, hookBodyVersion } from './agent-hook-envelope-build'
|
||||
import { AgentHookResultRetryScheduler } from './agent-hook-result-retry-scheduler'
|
||||
@@ -123,7 +123,14 @@ export class RelayAgentHookServer {
|
||||
drainAgentHookSpool({
|
||||
endpointDir: this.endpointDir,
|
||||
getPersistedLaunchTokenHash: () => undefined,
|
||||
ingest: (record) => this.ingestSpoolRecord(record)
|
||||
ingest: (record) =>
|
||||
ingestRelayAgentHookSpoolRecord(
|
||||
record,
|
||||
this.state,
|
||||
this.env,
|
||||
(event, source, env, version, options) =>
|
||||
this.applyEvent(event, source, env, version, options)
|
||||
)
|
||||
})
|
||||
} catch (err) {
|
||||
// Why: a downstream relay failure must not prevent the loopback listener from starting;
|
||||
@@ -289,6 +296,10 @@ export class RelayAgentHookServer {
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
} catch (err) {
|
||||
if (isAgentHookRequestTooLargeError(err)) {
|
||||
respondWithAgentHookRequestTooLarge(res, req)
|
||||
return
|
||||
}
|
||||
// Why (#11217): a remote host can run the same IDS; count truncations here so a blocked SSH
|
||||
// relay reports the cause instead of an anonymous "hook request failed".
|
||||
if (isHookRequestTruncatedError(err) && !destroyedBySlowlorisCap) {
|
||||
@@ -335,20 +346,4 @@ export class RelayAgentHookServer {
|
||||
this.lastEnvelopeMetaByPaneKey.set(event.paneKey, { source, env, version })
|
||||
this.forward(buildRelayHookEnvelope(event, source, env, version, options))
|
||||
}
|
||||
|
||||
private ingestSpoolRecord(record: SpoolRecord): void {
|
||||
if (!isAgentHookSource(record.source)) {
|
||||
return
|
||||
}
|
||||
const body = buildSpoolHookBody(record)
|
||||
const event = normalizeHookPayload(this.state, record.source, body, this.env, {
|
||||
deferCompactOwnershipToClient: true
|
||||
})
|
||||
if (!event) {
|
||||
return
|
||||
}
|
||||
this.applyEvent(event, record.source, hookBodyEnv(body), hookBodyVersion(body), {
|
||||
isReplay: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event'
|
||||
import { isAgentHookSource, type AgentHookSource } from '../shared/agent-hook-relay'
|
||||
import { buildSpoolHookBody, type SpoolRecord } from '../shared/agent-hook-spool'
|
||||
import type { HookListenerState } from '../shared/agent-hook-listener/listener-state'
|
||||
import { normalizeHookPayload } from '../shared/agent-hook-listener'
|
||||
import { hookBodyEnv, hookBodyVersion } from './agent-hook-envelope-build'
|
||||
|
||||
type ApplyRelayEvent = (
|
||||
event: AgentHookEventPayload,
|
||||
source: AgentHookSource,
|
||||
env?: string,
|
||||
version?: string,
|
||||
options?: { isReplay?: boolean }
|
||||
) => void
|
||||
|
||||
export function ingestRelayAgentHookSpoolRecord(
|
||||
record: SpoolRecord,
|
||||
state: HookListenerState,
|
||||
env: string,
|
||||
applyEvent: ApplyRelayEvent
|
||||
): void {
|
||||
if (!isAgentHookSource(record.source)) {
|
||||
return
|
||||
}
|
||||
const body = buildSpoolHookBody(record)
|
||||
const event = normalizeHookPayload(state, record.source, body, env, {
|
||||
deferCompactOwnershipToClient: true
|
||||
})
|
||||
if (!event) {
|
||||
return
|
||||
}
|
||||
applyEvent(event, record.source, hookBodyEnv(body), hookBodyVersion(body), { isReplay: true })
|
||||
}
|
||||
@@ -3,7 +3,11 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { __resetShellStartupEnvCache } from '../main/pty/shell-startup-env'
|
||||
import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir } from './plugin-overlay-env'
|
||||
import {
|
||||
inheritOmpXdgEnvironment,
|
||||
resolveOpenCodeSourceConfigDir,
|
||||
resolvePiSourceAgentDir
|
||||
} from './plugin-overlay-env'
|
||||
|
||||
describe('plugin overlay env source resolution', () => {
|
||||
let homeDir: string
|
||||
@@ -44,6 +48,28 @@ describe('plugin overlay env source resolution', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'resolves OMP profile and XDG roots from the remote shell',
|
||||
() => {
|
||||
const configDir = join(homeDir, 'omp-config')
|
||||
const dataDir = join(homeDir, 'xdg-data')
|
||||
mkdirSync(dataDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(homeDir, '.zshrc'),
|
||||
[
|
||||
`export PI_CONFIG_DIR="$HOME/${configDir.slice(homeDir.length + 1)}"`,
|
||||
`export XDG_DATA_HOME="$HOME/${dataDir.slice(homeDir.length + 1)}"`
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
const env: Record<string, string> = { HOME: homeDir, SHELL: '/bin/zsh' }
|
||||
expect(resolvePiSourceAgentDir(env, '/bin/zsh', 'omp', 'omp --profile review')).toBe(
|
||||
join(configDir, 'profiles', 'review', 'agent')
|
||||
)
|
||||
expect(inheritOmpXdgEnvironment(env, '/bin/zsh')).toEqual({ XDG_DATA_HOME: dataDir })
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'discovers overlay sources from a custom zsh ZDOTDIR',
|
||||
() => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { readSessionShellStartupEnvVar } from '../main/pty/shell-startup-env'
|
||||
import { join } from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
import {
|
||||
PRIMARY_AGENT_DIR_ENV_BY_KIND,
|
||||
SOURCE_AGENT_DIR_ENV_BY_KIND,
|
||||
@@ -33,11 +35,28 @@ export function resolveOpenCodeSourceConfigDir(
|
||||
export function resolvePiSourceAgentDir(
|
||||
env: Record<string, string>,
|
||||
shell: string | undefined,
|
||||
kind: PiAgentKind
|
||||
kind: PiAgentKind,
|
||||
launchCommand?: string
|
||||
): string | undefined {
|
||||
const sourceKey = SOURCE_AGENT_DIR_ENV_BY_KIND[kind]
|
||||
const primaryKey = PRIMARY_AGENT_DIR_ENV_BY_KIND[kind]
|
||||
|
||||
const ompProfile =
|
||||
kind === 'omp'
|
||||
? [readOmpProfileFromCommand(launchCommand), env.OMP_PROFILE, env.PI_PROFILE].find(
|
||||
(candidate) => candidate !== undefined && isSafeOmpProfile(candidate)
|
||||
)
|
||||
: undefined
|
||||
|
||||
if (kind === 'omp' && ompProfile) {
|
||||
const configuredRoot = firstNonEmpty(
|
||||
env.PI_CONFIG_DIR,
|
||||
readStartupEnv('PI_CONFIG_DIR', env, shell)
|
||||
)
|
||||
const configDir = configuredRoot ?? join(env.HOME ?? process.env.HOME ?? homedir(), '.omp')
|
||||
return join(configDir, 'profiles', ompProfile, 'agent')
|
||||
}
|
||||
|
||||
const sourceDir = firstNonEmpty(env[sourceKey])
|
||||
if (sourceDir) {
|
||||
return sourceDir
|
||||
@@ -65,5 +84,49 @@ export function resolvePiSourceAgentDir(
|
||||
) {
|
||||
return env[primaryKey]
|
||||
}
|
||||
|
||||
// OMP resolves its agent directory from PI_CONFIG_DIR before it populates
|
||||
// PI_CODING_AGENT_DIR. Resolve the launch profile up front so the relay
|
||||
// materializes the extension where the remote OMP process will load it.
|
||||
if (kind === 'omp') {
|
||||
const configuredRoot = firstNonEmpty(
|
||||
env.PI_CONFIG_DIR,
|
||||
readStartupEnv('PI_CONFIG_DIR', env, shell)
|
||||
)
|
||||
if (configuredRoot) {
|
||||
return join(configuredRoot, 'agent')
|
||||
}
|
||||
if (launchCommand?.trim()) {
|
||||
return join(env.HOME ?? process.env.HOME ?? homedir(), '.omp', 'agent')
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function readOmpProfileFromCommand(command: string | undefined): string | undefined {
|
||||
const match = command?.match(/(?:^|\s)--profile(?:=|\s+)(?:"([^"]+)"|'([^']+)'|([^\s]+))/)
|
||||
const profile = match?.[1] ?? match?.[2] ?? match?.[3]
|
||||
return profile && isSafeOmpProfile(profile) ? profile : undefined
|
||||
}
|
||||
|
||||
function isSafeOmpProfile(value: string): boolean {
|
||||
return /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(value)
|
||||
}
|
||||
|
||||
/** Carry shell-selected XDG roots into relay-spawned daemon children. */
|
||||
export function inheritOmpXdgEnvironment(
|
||||
env: Record<string, string>,
|
||||
shell: string | undefined
|
||||
): Record<string, string> {
|
||||
const next: Record<string, string> = {}
|
||||
for (const name of ['XDG_DATA_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME'] as const) {
|
||||
if (env[name] !== undefined) {
|
||||
continue
|
||||
}
|
||||
const value = readStartupEnv(name, env, shell) ?? process.env[name]
|
||||
if (value) {
|
||||
next[name] = value
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
} from '../shared/agent-hook-relay'
|
||||
import { publishAgentHookEnvelope } from './agent-hook-envelope-publication'
|
||||
import { assertPluginSourceUnderByteCap } from './plugin-source-limit'
|
||||
import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir } from './plugin-overlay-env'
|
||||
import {
|
||||
inheritOmpXdgEnvironment,
|
||||
resolveOpenCodeSourceConfigDir,
|
||||
resolvePiSourceAgentDir
|
||||
} from './plugin-overlay-env'
|
||||
import {
|
||||
detectExplicitPiAgentKindFromCommand,
|
||||
isPiCompatibleAgentType
|
||||
@@ -110,9 +114,10 @@ export class RelayAgentHookRuntime {
|
||||
}
|
||||
}
|
||||
if (kind === 'omp' || !hasLaunchCommand) {
|
||||
Object.assign(env, inheritOmpXdgEnvironment(context.env, context.shell))
|
||||
const sourceDir =
|
||||
kind === 'omp'
|
||||
? resolvePiSourceAgentDir(context.env, context.shell, 'omp')
|
||||
? resolvePiSourceAgentDir(context.env, context.shell, 'omp', launchCommandHint)
|
||||
: context.env.ORCA_OMP_SOURCE_AGENT_DIR
|
||||
const result = this.pluginOverlay.materializePi(overlayId, sourceDir, 'omp', {
|
||||
materializeDefaultHome: explicitKind === 'omp'
|
||||
|
||||
@@ -23,12 +23,14 @@ import { clearGrokSessionPathLookupCacheForTests } from './grok-session-paths'
|
||||
type FakeIncomingMessage = EventEmitter & {
|
||||
headers: IncomingHttpHeaders
|
||||
destroy: ReturnType<typeof vi.fn>
|
||||
pause: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function createReadableRequest(headers: IncomingHttpHeaders = {}): FakeIncomingMessage {
|
||||
const req = new EventEmitter() as FakeIncomingMessage
|
||||
req.headers = headers
|
||||
req.destroy = vi.fn(() => req.emit('close'))
|
||||
req.pause = vi.fn()
|
||||
return req
|
||||
}
|
||||
|
||||
@@ -144,7 +146,7 @@ describe('shared agent-hook-listener', () => {
|
||||
req.emit('data', Buffer.alloc(HOOK_REQUEST_MAX_BYTES + 1))
|
||||
|
||||
await expect(body).rejects.toThrow('payload too large')
|
||||
expect(req.destroy).toHaveBeenCalledTimes(1)
|
||||
expect(req.pause).toHaveBeenCalledTimes(1)
|
||||
expectRequestParserListenersReleased(req)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { IncomingMessage } from 'node:http'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
|
||||
import { classifyTruncatedHookRequest } from '../agent-hook-transport-interference'
|
||||
import { assertJsonTextStructureWithinLimits } from '../json-text-structure-limit'
|
||||
@@ -11,6 +11,35 @@ const AGENT_HOOK_JSON_STRUCTURE_LIMITS = {
|
||||
nestingDepth: 64
|
||||
} as const
|
||||
|
||||
/** The peer sent more than the listener can safely retain. */
|
||||
export class AgentHookRequestTooLargeError extends Error {
|
||||
readonly code = 'HOOK_REQUEST_TOO_LARGE'
|
||||
readonly maxBytes = HOOK_REQUEST_MAX_BYTES
|
||||
|
||||
constructor(readonly receivedBytes: number) {
|
||||
super(`payload too large (${receivedBytes} bytes; maximum ${HOOK_REQUEST_MAX_BYTES})`)
|
||||
this.name = 'AgentHookRequestTooLargeError'
|
||||
}
|
||||
}
|
||||
|
||||
export function isAgentHookRequestTooLargeError(
|
||||
error: unknown
|
||||
): error is AgentHookRequestTooLargeError {
|
||||
return error instanceof AgentHookRequestTooLargeError
|
||||
}
|
||||
|
||||
/** Send the bounded transport classification before closing the unread request. */
|
||||
export function respondWithAgentHookRequestTooLarge(
|
||||
res: ServerResponse,
|
||||
req: IncomingMessage
|
||||
): void {
|
||||
res.writeHead(413, { 'content-type': 'application/json' })
|
||||
res.end(
|
||||
JSON.stringify({ error: 'hook_request_too_large', maxBytes: HOOK_REQUEST_MAX_BYTES }),
|
||||
() => req.destroy()
|
||||
)
|
||||
}
|
||||
|
||||
export function parseAgentHookJson(content: string): unknown {
|
||||
// Why: Cursor on Windows writes UTF-8-with-BOM to the hook's stdin and `JSON.parse` rejects U+FEFF,
|
||||
// so the whole event was dropped. Strip exactly one leading BOM — not a trim — to keep every other
|
||||
@@ -63,8 +92,16 @@ export function readRequestBody(req: IncomingMessage): Promise<unknown> {
|
||||
// Why: bound by bytes (not UTF-16 units) and stop accumulating after rejection so a client can't push memory past the cap.
|
||||
const nextByteLength = byteLength + chunk.length
|
||||
if (nextByteLength > HOOK_REQUEST_MAX_BYTES) {
|
||||
settleReject(new Error('payload too large'))
|
||||
req.destroy()
|
||||
settleReject(new AgentHookRequestTooLargeError(nextByteLength))
|
||||
// Pause first so the caller can send a bounded 413 response. The
|
||||
// response completion callback destroys the unread request/socket.
|
||||
// The fallback keeps test doubles and unusual stream implementations
|
||||
// from retaining an unbounded body when pause is unavailable.
|
||||
if (typeof req.pause === 'function') {
|
||||
req.pause()
|
||||
} else {
|
||||
req.destroy()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (retained.length < nextByteLength) {
|
||||
|
||||
Reference in New Issue
Block a user