mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(claude): install SessionEnd for capable versions (#20530)
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' }] }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 } : {})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user