fix(claude): install SessionEnd for capable versions (#20530)

This commit is contained in:
Brennan Benson
2026-09-13 21:59:57 -07:00
committed by GitHub
parent c287a5d9b7
commit 33149fcde5
26 changed files with 512 additions and 79 deletions
+1
View File
@@ -6,6 +6,7 @@
"../src/main/agent-state-file-reader.ts",
"../src/main/agent-hooks/grok-replay-guard.ts",
"../src/main/claude/hook-script.ts",
"../src/main/claude/claude-session-end-hook-capability.ts",
"../src/main/agent-hooks/hook-stdin-contract.ts",
"../src/main/agent-hooks/hook-post-command.ts",
"../src/main/agent-hooks/hook-config-write-path.ts",
@@ -41,6 +41,7 @@ describe('detectLocalManagedAgentCliPresence', () => {
)
expect(result.codex?.state).toBe('found')
expect(result.codex).toEqual({ state: 'found', executablePath: '/bin/codex' })
expect(result.claude?.state).toBe('missing')
expect(probe.mock.calls.map(([filePath]) => filePath)).toEqual([
'/bin/codex',
@@ -63,6 +64,7 @@ describe('detectLocalManagedAgentCliPresence', () => {
)
expect(result.codex?.state).toBe('found')
expect(result.codex).toEqual({ state: 'found', executablePath: '/custom/bin/codex' })
expect(probe).toHaveBeenCalledWith('/custom/bin/codex')
})
@@ -81,6 +83,7 @@ describe('detectLocalManagedAgentCliPresence', () => {
)
expect(result.claude?.state).toBe('found')
expect(result.claude).toEqual({ state: 'found', executablePath: overridePath })
expect(probe).toHaveBeenCalledWith(overridePath)
})
@@ -14,7 +14,10 @@ import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-pa
export type LocalCliPresenceState = 'found' | 'missing' | 'unknown'
export type LocalCliPresenceByAgent = Partial<
Record<AgentHookTarget, { state: LocalCliPresenceState }>
Record<
AgentHookTarget,
{ state: 'found'; executablePath: string } | { state: Exclude<LocalCliPresenceState, 'found'> }
>
>
type FileProbe = {
@@ -113,18 +116,18 @@ async function probePathCandidate(
platform: NodeJS.Platform,
fileProbe: FileProbe,
pathExt?: string
): Promise<boolean> {
): Promise<string | null> {
if (!isSafeExecutableBasename(candidate)) {
return false
return null
}
for (const dir of dirs) {
for (const fileName of candidateFileNames(candidate, platform, pathExt)) {
if (await fileProbe.isExecutableFile(pathApiForPlatform(platform).join(dir, fileName))) {
return true
return pathApiForPlatform(platform).join(dir, fileName)
}
}
}
return false
return null
}
function isPlatformAbsolutePath(candidate: string, platform: NodeJS.Platform): boolean {
@@ -171,10 +174,17 @@ export async function detectLocalManagedAgentCliPresence(
candidates.add(override)
}
}
const found = new Set<string>()
const found = new Map<string, string>()
for (const candidate of candidates) {
if (await probePathCandidate(candidate, dirs, platform, fileProbe, options.pathExt)) {
found.add(candidate)
const executablePath = await probePathCandidate(
candidate,
dirs,
platform,
fileProbe,
options.pathExt
)
if (executablePath) {
found.set(candidate, executablePath)
}
}
const result: LocalCliPresenceByAgent = {}
@@ -187,13 +197,16 @@ export async function detectLocalManagedAgentCliPresence(
continue
}
result[target.agent] = (await fileProbe.isExecutableFile(expanded))
? { state: 'found' }
? { state: 'found', executablePath: expanded }
: { state: 'missing' }
continue
}
const targetCandidates = [...target.executableCandidates, ...(override ? [override] : [])]
result[target.agent] = targetCandidates.some((candidate) => found.has(candidate))
? { state: 'found' }
const executablePath = targetCandidates
.map((candidate) => found.get(candidate))
.find((candidate): candidate is string => candidate !== undefined)
result[target.agent] = executablePath
? { state: 'found', executablePath }
: { state: 'missing' }
}
return result
@@ -11,13 +11,18 @@ const mocks = vi.hoisted(() => ({
statusClaude: vi.fn(),
statusCodex: vi.fn(),
refreshClaude: vi.fn(),
refreshCodex: vi.fn()
refreshCodex: vi.fn(),
probeClaudeVersion: vi.fn()
}))
vi.mock('./local-agent-cli-presence', () => ({
detectLocalManagedAgentCliPresence: mocks.detect
}))
vi.mock('../claude/claude-session-end-hook-capability', () => ({
probeClaudeCliVersion: mocks.probeClaudeVersion
}))
vi.mock('./managed-agent-hook-registry', () => ({
MANAGED_AGENT_HOOK_INSTALLERS: [
['claude', mocks.installClaude],
@@ -71,6 +76,7 @@ describe('managed agent hook controls', () => {
mocks.removeCodexAsync.mockResolvedValue(status('codex', 'not_installed'))
mocks.refreshClaude.mockResolvedValue(undefined)
mocks.refreshCodex.mockResolvedValue(undefined)
mocks.probeClaudeVersion.mockResolvedValue(null)
})
it('installs only agents with positively detected CLIs', async () => {
@@ -159,6 +165,19 @@ describe('managed agent hook controls', () => {
])
})
it('forwards the detected Claude version to its installer', async () => {
mocks.detect.mockResolvedValue({
claude: { state: 'found', executablePath: '/opt/bin/claude' },
codex: { state: 'missing' }
})
mocks.probeClaudeVersion.mockResolvedValue('2.1.261')
await installManagedAgentHooks({ agentCmdOverrides: {} })
expect(mocks.probeClaudeVersion).toHaveBeenCalledWith('/opt/bin/claude')
expect(mocks.installClaude).toHaveBeenCalledWith({ cliVersion: '2.1.261' })
})
it('only refreshes scripts for the selected agents', async () => {
mocks.detect.mockResolvedValue({ codex: { state: 'found' } })
@@ -5,6 +5,7 @@ import {
} from '../../shared/managed-agent-hook-targets'
import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection'
import type { GlobalSettings } from '../../shared/global-settings-types'
import { probeClaudeCliVersion } from '../claude/claude-session-end-hook-capability'
import { detectLocalManagedAgentCliPresence } from './local-agent-cli-presence'
import {
MANAGED_AGENT_HOOK_ASYNC_REMOVERS,
@@ -12,7 +13,8 @@ import {
MANAGED_AGENT_HOOK_REMOVERS,
MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS,
MANAGED_AGENT_HOOK_STATUS_READERS,
type ManagedAgentHookInstaller
type ManagedAgentHookInstaller,
type ManagedAgentHookInstallOptions
} from './managed-agent-hook-registry'
export { MANAGED_AGENT_HOOK_INSTALLERS } from './managed-agent-hook-registry'
@@ -112,11 +114,11 @@ function selectedInstallers(options: InstallOptions): readonly ManagedAgentHookI
async function runInstaller(
entry: ManagedAgentHookInstaller,
onInstallError: InstallOptions['onInstallError'],
userInitiated?: boolean
options: ManagedAgentHookInstallOptions
): Promise<AgentHookInstallStatus> {
const [agent, install] = entry
try {
return await install({ userInitiated })
return await install(options)
} catch (error) {
console.error(`[agent-hooks] Failed to install ${agent} managed hooks:`, error)
try {
@@ -200,7 +202,16 @@ export async function installManagedAgentHooks(
)
continue
}
results.push(await runInstaller(entry, options.onInstallError, options.userInitiated))
const cliVersion =
agent === 'claude' && presence.executablePath
? await probeClaudeCliVersion(presence.executablePath)
: null
results.push(
await runInstaller(entry, options.onInstallError, {
...(options.userInitiated !== undefined ? { userInitiated: options.userInitiated } : {}),
...(cliVersion ? { cliVersion } : {})
})
)
}
return results
}
@@ -18,7 +18,7 @@ import { openClaudeHookService } from '../openclaude/hook-service'
// Why (#16441): Codex's installer awaits a codex app-server trust-grant session
// instead of blocking the main thread on spawnSync. Widening the tuple keeps the
// other thirteen agent services synchronous — the shared loop already awaits.
export type ManagedAgentHookInstallOptions = { userInitiated?: boolean }
export type ManagedAgentHookInstallOptions = { userInitiated?: boolean; cliVersion?: string }
export type ManagedAgentHookInstaller = readonly [
HookInstallAgent,
(
@@ -37,7 +37,7 @@ export type ManagedAgentHookAsyncRemover = readonly [
export type ManagedAgentHookStatusReader = readonly [HookInstallAgent, () => AgentHookInstallStatus]
export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] = [
['claude', () => claudeHookService.install()],
['claude', (options) => claudeHookService.install({ claudeVersion: options?.cliVersion })],
['openclaude', () => openClaudeHookService.install()],
['codex', () => codexHookService.install()],
['gemini', () => geminiHookService.install()],
@@ -21,4 +21,13 @@ describe('managed hook detection commands', () => {
it('maps detected TUI ids back to managed hook targets', () => {
expect(detectedManagedHookAgents(['codex', 'opencode', 'droid'])).toEqual(['codex', 'droid'])
})
it('requests a version only for Claude capability detection', () => {
const commands = buildManagedHookDetectionCommands(null, 'linux')
expect(commands.find((command) => command.id === 'claude')).toMatchObject({
reportVersion: true
})
expect(commands.find((command) => command.id === 'codex')?.reportVersion).toBeUndefined()
})
})
@@ -7,6 +7,7 @@ import { MANAGED_AGENT_HOOK_TARGETS } from '../../shared/managed-agent-hook-targ
import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection'
import type { GlobalSettings } from '../../shared/global-settings-types'
import type { TuiAgentDetectionCommand } from '../ipc/tui-agent-detection-commands'
import { parseClaudeCliVersion } from '../claude/claude-session-end-hook-capability'
export type ManagedHookDetectionSettings = Partial<
Pick<GlobalSettings, 'agentCmdOverrides' | 'disabledTuiAgents' | 'agentStatusHooksEnabled'>
@@ -26,7 +27,11 @@ export function buildManagedHookDetectionCommands(
if (override && isSafeOverrideExecutableToken(override)) {
commands.add(override)
}
return [...commands].map((cmd) => ({ id: target.tuiAgent, cmd }))
return [...commands].map((cmd) => ({
id: target.tuiAgent,
cmd,
...(target.agent === 'claude' ? { reportVersion: true as const } : {})
}))
}
)
}
@@ -40,3 +45,22 @@ export function detectedManagedHookAgents(values: unknown): AgentHookTarget[] {
(target) => target.agent
)
}
export function readManagedHookDetectionResult(value: unknown): {
agents: AgentHookTarget[]
claudeVersion: string | null
} {
if (value === null || typeof value !== 'object') {
return { agents: [], claudeVersion: null }
}
const agents = detectedManagedHookAgents(Reflect.get(value, 'agents'))
const versions = Reflect.get(value, 'versions')
const rawClaudeVersion =
versions !== null && typeof versions === 'object' ? Reflect.get(versions, 'claude') : null
return {
agents,
claudeVersion: parseClaudeCliVersion(
typeof rawClaudeVersion === 'string' ? rawClaudeVersion : null
)
}
}
+3 -1
View File
@@ -77,6 +77,7 @@ export async function installManagedHooks(options?: {
signal?: AbortSignal
hostKeyFingerprint?: string
agents?: readonly AgentHookTarget[]
claudeVersion?: string
}): Promise<ManagedHookInstallSummary> {
options?.signal?.throwIfAborted()
// Why: empty/omitted allowlist fails closed before any home/host probes.
@@ -101,7 +102,8 @@ export async function installManagedHooks(options?: {
{
grokHomeDir,
signal: options?.signal,
agents
agents,
...(options?.claudeVersion ? { claudeVersion: options.claudeVersion } : {})
}
)
return {
@@ -22,6 +22,8 @@ export type RemoteManagedHookInstallOptions = {
deferTrustUntilConfigToml?: boolean
/** Explicit GROK_HOME for remote runtimes that redirect Grok's config. */
grokHomeDir?: string
/** Version reported by Claude on this execution host. */
claudeVersion?: string
/** Stops before starting the next installer when the owning relay request
* is cancelled. Individual filesystem mutations remain atomic. */
signal?: AbortSignal
@@ -40,7 +42,13 @@ type RemoteManagedHookInstaller = readonly [
]
const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [
['claude', (sftp, remoteHome) => claudeHookService.installRemote(sftp, remoteHome)],
[
'claude',
(sftp, remoteHome, options) =>
claudeHookService.installRemote(sftp, remoteHome, {
claudeVersion: options?.claudeVersion
})
],
['openclaude', (sftp, remoteHome) => openClaudeHookService.installRemote(sftp, remoteHome)],
[
'codex',
+11 -6
View File
@@ -8,7 +8,7 @@ import type { SFTPWrapper } from 'ssh2'
import type { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
import {
buildManagedHookDetectionCommands,
detectedManagedHookAgents,
readManagedHookDetectionResult,
type ManagedHookDetectionSettings
} from './managed-hook-detection-commands'
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
@@ -30,11 +30,15 @@ export async function installWslGuestHooks(options: {
const { mux, guestHome, codexHomePath, distro, installHooks, settings, warn, installCodex } =
options
let agents
let claudeVersion: string | null = null
try {
const detected = (await mux.request('preflight.detectAgents', {
commands: buildManagedHookDetectionCommands(settings, 'linux')
})) as { agents?: unknown }
agents = detectedManagedHookAgents(detected?.agents)
const detected = readManagedHookDetectionResult(
await mux.request('preflight.detectAgents', {
commands: buildManagedHookDetectionCommands(settings, 'linux')
})
)
agents = detected.agents
claudeVersion = detected.claudeVersion
} catch (error) {
warn(
`[agent-hooks] WSL agent detection for '${distro}' failed: ${
@@ -64,7 +68,8 @@ export async function installWslGuestHooks(options: {
// runtime-host writer above; the relay adapter owns all other agents.
const remoteAgents = agents.filter((agent) => agent !== 'codex')
const results = await installHooks(createWslHookSftpAdapter(mux), guestHome, {
agents: remoteAgents
agents: remoteAgents,
...(claudeVersion ? { claudeVersion } : {})
})
const failed = results.filter((r) => r.state === 'error').length
if (failed > 0) {
@@ -180,9 +180,13 @@ describe('WslHookRelayManager', () => {
}
function guestTransport(
options: { registerInstallPlugins?: boolean; detectedAgents?: string[] } = {}
options: {
registerInstallPlugins?: boolean
detectedAgents?: string[]
claudeVersion?: string
} = {}
): MultiplexerTransport {
const { registerInstallPlugins = true, detectedAgents = ['codex'] } = options
const { registerInstallPlugins = true, detectedAgents = ['codex'], claudeVersion } = options
const harness = createGuestHarness()
harnesses.push(harness)
registerWslHookFsHandlers(harness.guestDispatcher, home)
@@ -190,7 +194,8 @@ describe('WslHookRelayManager', () => {
replayed: 0
}))
harness.guestDispatcher.onRequest('preflight.detectAgents', async () => ({
agents: detectedAgents
agents: detectedAgents,
...(claudeVersion ? { versions: { claude: claudeVersion } } : {})
}))
// A guest bundle predating the plugin overlay omits this handler (-32601).
if (registerInstallPlugins) {
@@ -281,6 +286,22 @@ describe('WslHookRelayManager', () => {
manager.disposeAll()
})
it('forwards the WSL guest Claude version to the shared remote installer', async () => {
const waitForSentinel = vi.fn(async () =>
guestTransport({ detectedAgents: ['claude'], claudeVersion: '2.1.261 (Claude Code)' })
)
const { manager, deps } = createManager({ waitForSentinel })
manager.ensureForDistro('Ubuntu')
await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1))
expect(deps.installHooks).toHaveBeenCalledWith(expect.anything(), home, {
agents: ['claude'],
claudeVersion: '2.1.261'
})
manager.disposeAll()
})
it('reinstalls into a newly resolved runtime home without restarting the relay', async () => {
const { manager, deps } = createManager({})
manager.ensureForDistro('Ubuntu', codexHome)
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import {
CLAUDE_SESSION_END_CAPABILITY_FLOOR,
claudeVersionSupportsSessionEnd,
parseClaudeCliVersion
} from './claude-session-end-hook-capability'
describe('Claude SessionEnd hook version capability', () => {
it('records 2.1.261 as the measured floor', () => {
expect(CLAUDE_SESSION_END_CAPABILITY_FLOOR).toBe('2.1.261')
})
it('extracts Claude Code version output', () => {
expect(parseClaudeCliVersion('2.1.261 (Claude Code)')).toBe('2.1.261')
})
it.each([
['2.1.260', false],
['2.1.261', true],
['2.2.0', true],
['unknown', false],
[undefined, false]
])('classifies %s as SessionEnd-capable: %s', (version, expected) => {
expect(claudeVersionSupportsSessionEnd(version)).toBe(expected)
})
})
@@ -0,0 +1,41 @@
import { hasReachedAppVersion, isValidAppVersion } from '../../shared/app-version'
import { runProcess } from '../../shared/child-process/run-process'
import path from 'node:path'
// 2.1.261 is the only version measured, not an established minimum.
export const CLAUDE_SESSION_END_CAPABILITY_FLOOR = '2.1.261'
export function parseClaudeCliVersion(output: string | null | undefined): string | null {
const version = output?.match(/\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\b/)?.[0]
return version && isValidAppVersion(version) ? version : null
}
export function claudeVersionSupportsSessionEnd(version: string | null | undefined): boolean {
const parsed = parseClaudeCliVersion(version)
return parsed !== null && hasReachedAppVersion(parsed, CLAUDE_SESSION_END_CAPABILITY_FLOOR)
}
export async function probeClaudeCliVersion(executablePath: string): Promise<string | null> {
try {
const pathKey = process.platform === 'win32' && process.env.Path !== undefined ? 'Path' : 'PATH'
const executableDir = path.dirname(executablePath)
const inheritedPath = process.env[pathKey]
const result = await runProcess({
program: executablePath,
args: ['--version'],
// Why: version-manager launchers often use `#!/usr/bin/env node`; the resolved CLI's sibling
// runtime must remain reachable even when Electron started with a thinner PATH.
env: {
...process.env,
[pathKey]: inheritedPath
? `${executableDir}${path.delimiter}${inheritedPath}`
: executableDir
},
timeoutMs: 5_000,
maxOutputBytes: 4_096
})
return result.code === 0 ? parseClaudeCliVersion(`${result.stdout}\n${result.stderr}`) : null
} catch {
return null
}
}
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { applyManagedHooks } from './hook-settings'
const SCRIPT_FILE_NAME = 'claude-hook.sh'
const MANAGED_COMMAND = '/home/dev/.orca/agent-hooks/claude-hook.sh'
const managedHook = { type: 'command' as const, command: MANAGED_COMMAND }
describe('Claude SessionEnd managed hook capability', () => {
it('installs SessionEnd beside SessionStart for the measured capable version', () => {
const written = applyManagedHooks({ hooks: {} }, managedHook, SCRIPT_FILE_NAME, {
claudeVersion: '2.1.261 (Claude Code)'
})
expect(written.hooks?.SessionEnd?.[0]?.hooks?.[0]?.command).toBe(MANAGED_COMMAND)
expect(written.hooks?.SessionStart?.[0]?.hooks?.[0]?.command).toBe(MANAGED_COMMAND)
})
it.each(['2.1.260', 'unknown', undefined])(
'retains the legacy event set for an incapable or unverified host (%s)',
(claudeVersion) => {
const written = applyManagedHooks({ hooks: {} }, managedHook, SCRIPT_FILE_NAME, {
claudeVersion
})
expect(written.hooks?.SessionEnd).toBeUndefined()
expect(written.hooks?.SessionStart).toBeDefined()
}
)
it('removes only Orca SessionEnd during a capability downgrade', () => {
const capable = applyManagedHooks(
{
hooks: {
SessionEnd: [{ hooks: [{ type: 'command', command: 'echo user-session-end' }] }]
}
},
managedHook,
SCRIPT_FILE_NAME,
{ claudeVersion: '2.1.261' }
)
const downgraded = applyManagedHooks(capable, managedHook, SCRIPT_FILE_NAME, {
claudeVersion: '2.1.260'
})
expect(downgraded.hooks?.SessionEnd).toEqual([
{ hooks: [{ type: 'command', command: 'echo user-session-end' }] }
])
})
})
+18 -4
View File
@@ -47,6 +47,10 @@ type ClaudeHookServiceOptions = {
settings: ClaudeCompatibleHookSettings
}
type ClaudeHookInstallOptions = {
claudeVersion?: string
}
const DEFAULT_CLAUDE_HOOK_SERVICE_OPTIONS: ClaudeHookServiceOptions = {
agent: 'claude',
displayName: 'Claude',
@@ -122,7 +126,7 @@ export class ClaudeHookService {
)
}
install(): AgentHookInstallStatus {
install(options: ClaudeHookInstallOptions = {}): AgentHookInstallStatus {
const configPath = getConfigPath(this.options.settings)
const scriptPath = getManagedScriptPath(this.options.settings)
const config = readHooksJson(configPath)
@@ -140,7 +144,8 @@ export class ClaudeHookService {
let nextConfig = applyManagedHooks(
config,
hook,
getManagedScriptFileName(this.options.settings)
getManagedScriptFileName(this.options.settings),
this.options.agent === 'claude' ? options : undefined
)
writeManagedScript(
scriptPath,
@@ -182,7 +187,11 @@ export class ClaudeHookService {
}
// Why: install the Claude hook on the remote box (via SFTP); POSIX-only by design (Windows-remote deferred).
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
async installRemote(
sftp: SFTPWrapper,
remoteHome: string,
options: ClaudeHookInstallOptions = {}
): Promise<AgentHookInstallStatus> {
// Why: remote Windows is unsupported; local process.platform cannot identify the remote OS.
const remoteConfigPath = getRemoteConfigPath(remoteHome, this.options.settings)
const remoteScriptFileName = getPosixManagedScriptFileName(this.options.settings)
@@ -202,7 +211,12 @@ export class ClaudeHookService {
// Why: settings resolve HOME at runtime while SFTP still targets the discovered remote home.
const hook = buildManagedCommandHook(getRemoteManagedCommand(remoteScriptPath))
const nextConfig = applyManagedHooks(config, hook, remoteScriptFileName)
const nextConfig = applyManagedHooks(
config,
hook,
remoteScriptFileName,
this.options.agent === 'claude' ? options : undefined
)
// Why: write scripts before settings to avoid settings pointing to missing scripts.
// Why: SSH scripts always use POSIX .sh paths, regardless of the local OS.
+25 -2
View File
@@ -16,6 +16,7 @@ import {
import { wrapRuntimeHomeHookCommand } from '../agent-hooks/runtime-home-hook-command'
import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command'
import { isGitBashAvailable } from '../git-bash'
import { claudeVersionSupportsSessionEnd } from './claude-session-end-hook-capability'
export type ClaudeCompatibleHookSettings = {
configDirName: '.claude' | '.openclaude'
@@ -101,6 +102,15 @@ export const CLAUDE_EVENTS = [
}
] as const
const CLAUDE_SESSION_END_EVENT = {
eventName: 'SessionEnd',
definition: { hooks: [{ type: 'command', command: '' }] }
} as const
export type ApplyManagedClaudeHooksOptions = {
claudeVersion?: string
}
export function getConfigPath(settings = CLAUDE_HOOK_SETTINGS): string {
return join(homedir(), settings.configDirName, 'settings.json')
}
@@ -212,12 +222,15 @@ export function getRemoteManagedCommand(scriptPath: string): string {
export function applyManagedHooks(
config: HooksConfig,
hook: HookCommandConfig,
scriptFileName = getManagedScriptFileName()
scriptFileName = getManagedScriptFileName(),
options: ApplyManagedClaudeHooksOptions = {}
): HooksConfig {
const nextHooks = { ...config.hooks }
const isManagedCommand = createManagedCommandMatcher(scriptFileName)
const sessionEndCapable = claudeVersionSupportsSessionEnd(options.claudeVersion)
const events = sessionEndCapable ? [...CLAUDE_EVENTS, CLAUDE_SESSION_END_EVENT] : CLAUDE_EVENTS
for (const event of CLAUDE_EVENTS) {
for (const event of events) {
const current = Array.isArray(nextHooks[event.eventName]) ? nextHooks[event.eventName] : []
const cleaned = removeManagedCommands(current, isManagedCommand)
const definition: HookDefinition = {
@@ -227,6 +240,16 @@ export function applyManagedHooks(
nextHooks[event.eventName] = [...cleaned, definition]
}
if (!sessionEndCapable) {
const current = Array.isArray(nextHooks.SessionEnd) ? nextHooks.SessionEnd : []
const cleaned = removeManagedCommands(current, isManagedCommand)
if (cleaned.length === 0) {
delete nextHooks.SessionEnd
} else {
nextHooks.SessionEnd = cleaned
}
}
return { ...config, hooks: nextHooks }
}
@@ -128,4 +128,34 @@ describe('SshRelaySession managed hooks', () => {
muxRequestMock.mock.invocationCallOrder[managedIndex]
)
})
it('forwards the execution-host Claude version to the remote installer', async () => {
muxRequestMock.mockImplementation(async (method: string) => {
if (method === 'preflight.detectAgents') {
return {
agents: ['claude'],
versions: { claude: '2.1.261 (Claude Code)' }
}
}
return method === AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD
? { installers: 1, errors: 0 }
: { ok: true }
})
const { mockStore, mockPortForward, getMainWindow } = createMockDeps()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: establish only reads these mocked connection members in this harness.
const connection = {
sftp: vi.fn(),
getHostKeyFingerprint: vi.fn(() => 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')
} as unknown as SshConnection
const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward)
await session.establish(connection)
await vi.waitFor(() =>
expect(muxRequestMock).toHaveBeenCalledWith(AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, {
hostKeyFingerprint: 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
agents: ['claude'],
claudeVersion: '2.1.261'
})
)
})
})
+9 -6
View File
@@ -26,7 +26,7 @@ import { agentHookServer } from '../agent-hooks/server'
import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
import {
buildManagedHookDetectionCommands,
detectedManagedHookAgents
readManagedHookDetectionResult
} from '../agent-hooks/managed-hook-detection-commands'
import {
AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD,
@@ -1378,17 +1378,20 @@ export class SshRelaySession {
try {
const store = this.store as { getSettings?: Store['getSettings'] }
const detected = (await mux.request('preflight.detectAgents', {
commands: buildManagedHookDetectionCommands(store.getSettings?.() ?? null, 'linux')
})) as { agents?: unknown }
const agents = detectedManagedHookAgents(detected?.agents)
const detected = readManagedHookDetectionResult(
await mux.request('preflight.detectAgents', {
commands: buildManagedHookDetectionCommands(store.getSettings?.() ?? null, 'linux')
})
)
const agents = detected.agents
if (agents.length === 0 || (shouldContinue && !shouldContinue())) {
return
}
const hostKeyFingerprint = this.requireReadyConnection().getHostKeyFingerprint?.()
const params = {
...(hostKeyFingerprint ? { hostKeyFingerprint } : {}),
agents
agents,
...(detected.claudeVersion ? { claudeVersion: detected.claudeVersion } : {})
}
const result = (await mux.request(AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, params)) as {
errors?: unknown
+18
View File
@@ -94,4 +94,22 @@ describe('registerManagedHookInstaller', () => {
'invalid_managed_hook_agents'
)
})
it('forwards only a parseable Claude execution-host version', async () => {
const installManagedHooks = vi.fn().mockResolvedValue({ installers: 1, errors: 0 })
const handler = captureHandler(() => ({ installManagedHooks }))
await handler({ agents: ['claude'], claudeVersion: '2.1.261 (Claude Code)' }, context())
await handler({ agents: ['claude'], claudeVersion: 'unknown' }, context())
expect(installManagedHooks).toHaveBeenNthCalledWith(1, {
signal: undefined,
agents: ['claude'],
claudeVersion: '2.1.261'
})
expect(installManagedHooks).toHaveBeenNthCalledWith(2, {
signal: undefined,
agents: ['claude']
})
})
})
+11 -1
View File
@@ -6,6 +6,7 @@ import {
import type { RelayDispatcher, RequestContext } from './dispatcher'
import type { AgentHookTarget } from '../shared/agent-hook-types'
import { isManagedAgentHookTarget } from '../shared/managed-agent-hook-targets'
import { parseClaudeCliVersion } from '../main/claude/claude-session-end-hook-capability'
export type ManagedHookInstallSummary = {
installers: number
@@ -17,6 +18,7 @@ export type ManagedHookRuntime = {
signal?: AbortSignal
hostKeyFingerprint?: string
agents?: readonly AgentHookTarget[]
claudeVersion?: string
}) => Promise<ManagedHookInstallSummary>
}
@@ -41,6 +43,12 @@ function readAgents(params: unknown): AgentHookTarget[] {
return [...new Set(raw)]
}
function readClaudeVersion(params: unknown): string | undefined {
const raw =
params !== null && typeof params === 'object' ? Reflect.get(params, 'claudeVersion') : null
return parseClaudeCliVersion(typeof raw === 'string' ? raw : null) ?? undefined
}
let managedHookRuntime: ManagedHookRuntime | null = null
function loadManagedHookRuntime(): ManagedHookRuntime {
@@ -62,10 +70,12 @@ export function registerManagedHookInstaller(
context.signal?.throwIfAborted()
const hostKeyFingerprint = readHostKeyFingerprint(params)
const agents = readAgents(params)
const claudeVersion = readClaudeVersion(params)
return await loadRuntime().installManagedHooks({
signal: context.signal,
...(hostKeyFingerprint ? { hostKeyFingerprint } : {}),
agents
agents,
...(claudeVersion ? { claudeVersion } : {})
})
}
)
+42 -2
View File
@@ -1,8 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { buildPosixCommandPathLookupScript } from '../shared/posix-command-path-lookup'
const { execFileAsyncMock } = vi.hoisted(() => ({
execFileAsyncMock: vi.fn()
const { execFileAsyncMock, runProcessMock } = vi.hoisted(() => ({
execFileAsyncMock: vi.fn(),
runProcessMock: vi.fn()
}))
const {
@@ -30,6 +31,7 @@ vi.mock('../main/wsl', () => ({
listWslDistrosAsync: listWslDistrosAsyncMock
}))
vi.mock('../main/git-bash', () => ({ isGitBashAvailable: isGitBashAvailableMock }))
vi.mock('../shared/child-process/run-process', () => ({ runProcess: runProcessMock }))
import {
buildCommandLookupSpec,
@@ -65,6 +67,7 @@ function fishLookupArgs(command: string): string[] {
beforeEach(() => {
execFileAsyncMock.mockReset()
runProcessMock.mockReset()
isPwshAvailableAsyncMock.mockReset()
isWslAvailableAsyncMock.mockReset()
listWslDistrosAsyncMock.mockReset()
@@ -237,6 +240,43 @@ describe('hasAbsoluteCommandPath', () => {
})
describe('PreflightHandler', () => {
it('reports a requested version from the resolved execution-host binary', async () => {
execFileAsyncMock.mockResolvedValue({
stdout: '__ORCA_AGENT_PATH__/home/dev/.local/bin/claude\n'
})
runProcessMock.mockResolvedValue({
code: 0,
signal: null,
stdout: '2.1.261 (Claude Code)\n',
stderr: '',
timedOut: false
})
const requestHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>()
const dispatcher = {
onRequest: vi.fn(
(method: string, handler: (params: Record<string, unknown>) => Promise<unknown>) => {
requestHandlers.set(method, handler)
}
)
}
new PreflightHandler(dispatcher as never)
await expect(
requestHandlers.get('preflight.detectAgents')!({
commands: [{ id: 'claude', cmd: 'claude', reportVersion: true }]
})
).resolves.toEqual({
agents: ['claude'],
versions: { claude: '2.1.261 (Claude Code)' }
})
expect(runProcessMock).toHaveBeenCalledWith(
expect.objectContaining({
program: '/home/dev/.local/bin/claude',
args: ['--version']
})
)
})
it('honors required commands when reporting detected agents', async () => {
execFileAsyncMock.mockImplementation(async (_file, args) => {
const script = String(args[1])
+91 -32
View File
@@ -8,6 +8,7 @@ import { isPwshAvailableAsync } from '../main/pwsh'
import { isWslAvailableAsync, listWslDistrosAsync } from '../main/wsl'
import { isGitBashAvailable } from '../main/git-bash'
import { buildPosixCommandPathLookupScript } from '../shared/posix-command-path-lookup'
import { runProcess } from '../shared/child-process/run-process'
const execFileAsync = promisify(execFile)
@@ -28,6 +29,7 @@ type AgentDetectionRuntime = NodeJS.Platform | 'wsl'
type AgentDetectionCommand = {
id: string
cmd: string
reportVersion?: true
requiredCommands?: readonly string[]
unsupportedRuntimes?: readonly AgentDetectionRuntime[]
}
@@ -54,7 +56,10 @@ export class PreflightHandler {
// Why: the client sends the command list rather than importing TUI_AGENT_CONFIG
// on the relay side. This keeps the relay bundle minimal and makes the protocol
// self-describing — the relay doesn't need to know the agent catalog.
private async detectAgents(params: Record<string, unknown>): Promise<{ agents: string[] }> {
private async detectAgents(params: Record<string, unknown>): Promise<{
agents: string[]
versions?: Record<string, string>
}> {
const commands = params.commands as AgentDetectionCommand[]
if (!Array.isArray(commands)) {
return { agents: [] }
@@ -70,26 +75,40 @@ export class PreflightHandler {
const results = await Promise.all(
probeCommands.map(async (cmd) => ({
cmd,
installed: await this.isCommandOnPath(cmd)
executablePath: await resolveCommandPathForRelay(cmd)
}))
)
const foundCommands = new Set(
results.filter((result) => result.installed).map(({ cmd }) => cmd)
results.filter((result) => result.executablePath !== null).map(({ cmd }) => cmd)
)
const detectedCommands = commands.filter(
(command) =>
!isDetectionUnsupportedInRuntime(command, process.platform) &&
foundCommands.has(command.cmd) &&
(command.requiredCommands ?? []).every((required) => foundCommands.has(required))
)
const versions: Record<string, string> = {}
for (const command of detectedCommands) {
if (
command.id !== 'claude' ||
command.reportVersion !== true ||
versions.claude !== undefined
) {
continue
}
const executablePath = results.find((result) => result.cmd === command.cmd)?.executablePath
if (!executablePath) {
continue
}
const version = await probeCommandVersion(executablePath)
if (version) {
versions[command.id] = version
}
}
return {
agents: [
...new Set(
commands
.filter(
(command) =>
!isDetectionUnsupportedInRuntime(command, process.platform) &&
foundCommands.has(command.cmd) &&
(command.requiredCommands ?? []).every((required) => foundCommands.has(required))
)
.map(({ id }) => id)
)
]
agents: [...new Set(detectedCommands.map(({ id }) => id))],
...(Object.keys(versions).length > 0 ? { versions } : {})
}
}
@@ -119,8 +138,33 @@ export class PreflightHandler {
// startup files sourced. Ask the user's configured shell so agent dirs added
// by zsh/bash/fish startup hooks match the remote terminal experience.
// Windows has no POSIX shell on native OpenSSH hosts, so use where.exe there.
private async isCommandOnPath(command: string): Promise<boolean> {
return isCommandOnPathForRelay(command)
}
async function probeCommandVersion(executablePath: string): Promise<string | null> {
try {
const env = buildRelayCommandEnv(process.env, process.platform)
const pathKey = process.platform === 'win32' && env.Path !== undefined ? 'Path' : 'PATH'
const executableDir = path.dirname(executablePath)
const inheritedPath = env[pathKey]
const result = await runProcess({
program: executablePath,
args: ['--version'],
env: {
...env,
[pathKey]: inheritedPath
? `${executableDir}${path.delimiter}${inheritedPath}`
: executableDir
},
timeoutMs: 5_000,
maxOutputBytes: 4_096
})
if (result.code !== 0) {
return null
}
const output = `${result.stdout}\n${result.stderr}`.trim()
return output.length > 0 ? output : null
} catch {
return null
}
}
@@ -172,6 +216,13 @@ export async function isCommandOnPathForRelay(
command: string,
options: RelayCommandLookupOptions = {}
): Promise<boolean> {
return (await resolveCommandPathForRelay(command, options)) !== null
}
export async function resolveCommandPathForRelay(
command: string,
options: RelayCommandLookupOptions = {}
): Promise<string | null> {
const platform = options.platform ?? process.platform
const env = options.env ?? process.env
const specs = buildCommandLookupSpecs(command, platform, env, options.accountLoginShell)
@@ -184,31 +235,39 @@ export async function isCommandOnPathForRelay(
timeout: 5000,
...(spec.windowsHide ? { windowsHide: true } : {})
})
if (hasAbsoluteCommandPath(stdout, platform)) {
return true
const resolvedPath = getAbsoluteCommandPath(stdout, platform)
if (resolvedPath) {
return resolvedPath
}
} catch {
// Try the inherited-PATH fallback before reporting the agent missing.
}
}
return false
return null
}
export function hasAbsoluteCommandPath(output: string, platform: NodeJS.Platform): boolean {
return getAbsoluteCommandPath(output, platform) !== null
}
function getAbsoluteCommandPath(output: string, platform: NodeJS.Platform): string | null {
const pathOps = platform === 'win32' ? win32 : path
return output
.split(/\r?\n/)
.map((line) => line.trim())
.some((line) => {
const resolvedPath =
platform === 'win32'
? line
: line.startsWith(AGENT_PATH_PREFIX)
? line.slice(AGENT_PATH_PREFIX.length)
: ''
return pathOps.isAbsolute(resolvedPath)
})
return (
output
.split(/\r?\n/)
.map((line) => line.trim())
.map((line) => {
const resolvedPath =
platform === 'win32'
? line
: line.startsWith(AGENT_PATH_PREFIX)
? line.slice(AGENT_PATH_PREFIX.length)
: ''
return pathOps.isAbsolute(resolvedPath) ? resolvedPath : null
})
.find((resolvedPath): resolvedPath is string => resolvedPath !== null) ?? null
)
}
function buildPosixCommandLookupSpec(command: string, shell: string): CommandLookupSpec {
@@ -26,8 +26,8 @@ const CLAUDE_SESSION_OWNER_EVENTS: ReadonlySet<string> = new Set([
])
/** A pane whose `session_id` changed is running a different conversation, so claims the previous one
* owned are void — the hook-independent backstop for /clear, relaunch and resume, which emit no
* terminating hook (SessionEnd covers about a third of exit paths).
* owned are void — the hook-independent backstop for /clear, relaunch and resume. Modern Claude
* emits SessionEnd on /clear, but Orca previously did not install it and older binaries emit none.
*
* Voids only what the replaced session provably owned. Deliberately NOT voided:
* - `claudeRunningNonAgentTaskPaneKeys`: a background shell is an OS process that survives /clear,
+2
View File
@@ -217,6 +217,8 @@ export type AgentHookInstallManagedHooksParams = {
hostKeyFingerprint?: string
/** Positively detected and enabled agents allowed to mutate remote config. */
agents: readonly AgentHookTarget[]
/** Execution-host Claude version; absent means retain the legacy hook set. */
claudeVersion?: string
}
/** Feature-flag env var. Read once at process start by Orca and the relay.
@@ -9,6 +9,8 @@ import {
export type TuiAgentDetectionCommand = {
id: TuiAgent
cmd: string
/** Ask an execution host to report this CLI's `--version` output when found. */
reportVersion?: true
requiredCommands?: readonly string[]
unsupportedRuntimes?: readonly TuiAgentDetectionRuntime[]
}