fix(claude): ignore a launcher's own version when choosing hook events (#36119)

* fix(claude): ignore a launcher's own version when choosing hook events

* Bind Claude capability versions to identified output

* Complete Claude version probe test process results
This commit is contained in:
Brennan Benson
2026-10-29 16:08:53 -07:00
committed by GitHub
parent a874ff9d76
commit 3699db8b64
10 changed files with 297 additions and 27 deletions
+1 -1
View File
@@ -122,7 +122,7 @@ it('probes the agent behind a launcher with tree teardown', async () => {
})
it('probes Claude through its prefix with the same tree teardown', async () => {
prints('2.1.280')
prints('2.1.280 (Claude Code)')
await expect(
probeClaudeCliVersion(INPUT.program, {
cwd: INPUT.cwd,
@@ -7,7 +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 '../../shared/tui-agent-detection-commands'
import { parseClaudeCliVersion } from '../claude/claude-hook-event-versions'
import { parseClaudeCliVersionOutput } from '../claude/claude-hook-event-versions'
export type ManagedHookDetectionSettings = Partial<
Pick<
@@ -67,7 +67,7 @@ export function readManagedHookDetectionResult(value: unknown): {
: null
return {
agents,
claudeVersion: parseClaudeCliVersion(
claudeVersion: parseClaudeCliVersionOutput(
typeof rawClaudeVersion === 'string' ? rawClaudeVersion : null
)
}
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { RelayDispatcher } from '../../wsl-guest/dispatcher'
import { registerWslHookFsHandlers } from '../../wsl-guest/wsl-hook-fs-bridge'
import { SshChannelMultiplexer, type MultiplexerTransport } from '../ssh/ssh-channel-multiplexer'
import { createWslHookSftpAdapter } from './wsl-hook-fs-adapter'
import { createWslHookSftpAdapter, installWslGuestHooks } from './wsl-hook-fs-adapter'
import {
installRemoteManagedAgentHooks,
REMOTE_MANAGED_HOOK_INSTALLER_AGENTS
@@ -123,7 +123,8 @@ describe.skipIf(process.platform === 'win32')(
it('runs the unchanged remote managed hook installers against a WSL guest home', async () => {
const adapter = createWslHookSftpAdapter(harness.mux)
const results = await installRemoteManagedAgentHooks(adapter, home, {
agents: REMOTE_MANAGED_HOOK_INSTALLER_AGENTS
agents: REMOTE_MANAGED_HOOK_INSTALLER_AGENTS,
claudeVersion: '2.1.310'
})
expect(results.length).toBeGreaterThan(0)
@@ -132,7 +133,34 @@ describe.skipIf(process.platform === 'win32')(
const claudeSettings = JSON.parse(
readFileSync(join(home, '.claude', 'settings.json'), 'utf8')
)
expect(claudeSettings.hooks).toBeTruthy()
expect(claudeSettings.hooks.StopFailure).toBeTruthy()
let rawVersion = '11.17.0'
harness.guestDispatcher.onRequest('preflight.detectAgents', async () => ({
agents: ['claude'],
versions: { claude: rawVersion }
}))
const reinstall = () =>
installWslGuestHooks({
mux: harness.mux,
guestHome: home,
codexHomePath: null,
distro: 'Ubuntu',
installHooks: installRemoteManagedAgentHooks,
settings: null,
warn: (message) => {
throw new Error(message)
},
installCodex: async () => null
})
await reinstall()
expect(JSON.parse(readFileSync(join(home, '.claude', 'settings.json'), 'utf8'))).toEqual(
claudeSettings
)
rawVersion = 'launcher 11.17.0\n1.0.64 (Claude Code)'
await reinstall()
const oldSettings = JSON.parse(readFileSync(join(home, '.claude', 'settings.json'), 'utf8'))
expect(oldSettings.hooks.StopFailure).toBeUndefined()
expect(oldSettings.hooks.PreToolUse).toBeTruthy()
const script = readFileSync(join(home, '.orca', 'agent-hooks', 'claude-hook.sh'), 'utf8')
expect(script).toContain('/hook/claude')
}, 20_000)
@@ -465,21 +493,30 @@ describe('WslHookRelayManager', () => {
expect(manager.getGuestEndpointFilePath('Ubuntu')).toBeNull()
})
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 })
it.each([
['2.1.221 (Claude Code)\r\n', '2.1.221'],
['launcher 0.1.0\n2.1.310 (Claude Code)', '2.1.310'],
['launcher 11.17.0\n1.0.64 (Claude Code)', '1.0.64'],
['11.17.0', undefined],
[undefined, undefined]
] as const)(
'forwards identified WSL version without losing agent presence: %s',
async (raw, expected) => {
const waitForSentinel = vi.fn(async () =>
guestTransport({ detectedAgents: ['claude'], claudeVersion: raw })
)
const { manager, deps } = createManager({ waitForSentinel })
manager.ensureForDistro('Ubuntu')
await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1))
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()
})
expect(deps.installHooks).toHaveBeenCalledWith(expect.anything(), home, {
agents: ['claude'],
...(expected ? { claudeVersion: expected } : {})
})
manager.disposeAll()
}
)
it('reinstalls into a newly resolved runtime home without restarting the relay', async () => {
const { manager, deps } = createManager({})
@@ -1,3 +1,4 @@
import { runProcess } from '@orca/process-host'
import { spawnSync } from 'node:child_process'
import {
chmodSync,
@@ -36,6 +37,10 @@ import {
} from './claude-profile-installed-router'
import { ClaudeObservedConfigDirs } from './claude-observed-config-dirs'
vi.mock('@orca/process-host', () => ({ runProcess: vi.fn() }))
const command = vi.hoisted(() => ({ path: '/unused-claude' }))
vi.mock('../codex-cli/command', () => ({ resolveClaudeCommand: () => command.path }))
const shell = vi.hoisted(() => ({ hang: false }))
vi.mock('../startup/login-shell-environment', () => ({
resolveLoginShellEnvironment: () =>
@@ -156,6 +161,49 @@ function claudeShellProbe(root: string): (env: Record<string, string>, rc?: stri
}
describe('ClaudeProfileRouter', () => {
it.each([
['launcher 0.1.0\n2.1.310 (Claude Code)', '2.1.310'],
['11.17.0', undefined]
] as const)(
'passes identified version or unknown to enabled profile setup: %s',
async (stdout, version) => {
const f = fixture()
f.settings.agentStatusHooksEnabled = true
command.path = join(f.root, 'claude')
vi.mocked(runProcess).mockResolvedValue({
stdout,
stderr: '',
code: 0,
signal: null,
timedOut: false
})
const runSetup = vi.fn(async () => ({
outcome: 'prepared' as const,
warnings: [],
surfaces: {}
}))
const router = new ClaudeProfileRouter({
getSettings: () => f.settings,
dataRoot: f.dataRoot,
userHome: f.userHome,
env: {},
runSetup
})
await router.prepareAccount('a')
await router.prepareAccount('b')
expect(runSetup).toHaveBeenCalledTimes(2)
for (const accountId of ['a', 'b']) {
expect(runSetup).toHaveBeenCalledWith(
expect.objectContaining({
profile: expect.objectContaining({ accountId }),
hooks: true,
claudeVersion: version
})
)
}
}
)
it('runs an account with no login of its own on System default while that is signed in to it', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const f = fixture()
@@ -18,6 +18,7 @@ vi.mock('node:os', async (original) => ({
homedir: () => state.home
}))
vi.mock('electron', () => ({ app: { getPath: () => state.home } }))
import { parseClaudeCliVersionOutput } from '../claude/claude-hook-event-versions'
import { ClaudeHookService } from '../claude/hook-service'
import { describeClaudeProfile } from './claude-profile-paths'
import { provisionClaudeAccountProfile } from './claude-profile-setup'
@@ -81,6 +82,16 @@ describe('Claude account profile setup', () => {
})
expect(realpathSync(join(home, 'projects'))).toBe(realpathSync(join(f.defaultHome, 'projects')))
expect(existsSync(join(home, '.credentials.json'))).toBe(false)
const unknown = parseClaudeCliVersionOutput('11.17.0') ?? undefined
const refreshed = await provisionClaudeAccountProfile({
dataRoot: f.dataRoot,
profile: describeClaudeProfile(f.dataRoot, 'a', local),
userHome: state.home,
installHooks: (target) => f.service.install({ ...target, claudeVersion: unknown }),
platform: 'linux'
})
expect(refreshed.warnings).toEqual([])
expect(JSON.parse(readFileSync(join(home, 'settings.json'), 'utf8'))).toEqual(settings)
expect((await f.setup()).warnings).toEqual([])
})
itLinks('shares plugins Claude wrote into the folder before its first setup', async () => {
@@ -2,6 +2,7 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { parseClaudeCliVersionOutput } from './claude-hook-event-versions'
import { claudeDiffPanelMayAutoOpen, claudeDiffPanelStorePath } from './claude-diff-panel-auto-open'
let home: string
@@ -43,8 +44,16 @@ describe('claudeDiffPanelMayAutoOpen', () => {
})
it('answers false for a Claude older than the panel, or an unknown version', async () => {
for (const [output, expected] of [
['11.17.0', false],
['launcher 11.17.0\n1.0.64 (Claude Code)', false],
['launcher 0.1.0\n2.1.310 (Claude Code)', true]
] as const) {
expect(await mayAutoOpen(200, { version: parseClaudeCliVersionOutput(output) })).toBe(
expected
)
}
expect(await mayAutoOpen(200, { version: '2.1.259' })).toBe(false)
expect(await mayAutoOpen(200, { version: null })).toBe(false)
expect(await mayAutoOpen(200, { version: '2.1.260' })).toBe(true)
})
@@ -1,7 +1,11 @@
import { describe, expect, it } from 'vitest'
import type { HooksConfig } from '../agent-hooks/installer-utils'
import fixture from './__fixtures__/claude-hook-event-enums.json'
import { CLAUDE_HOOK_EVENT_FIRST_VERSIONS } from './claude-hook-event-versions'
import { readManagedHookDetectionResult } from '../agent-hooks/managed-hook-detection-commands'
import {
parseClaudeCliVersionOutput,
CLAUDE_HOOK_EVENT_FIRST_VERSIONS
} from './claude-hook-event-versions'
import { getClaudeManagedHookPlan } from './claude-managed-hook-events'
import { applyManagedHooks } from './hook-settings'
@@ -134,9 +138,10 @@ describe('Claude managed hook events by resolved version', () => {
}
)
it.each([undefined, 'unknown'])(
it.each([undefined, '11.17.0', '2.1.310\nwrapper for (Claude Code)'])(
'leaves Orca entries a version-aware install wrote untouched when the version is %s',
(version) => {
(output) => {
const version = parseClaudeCliVersionOutput(output) ?? undefined
const current = install(userOwnedSettings(), '2.1.261')
expect(JSON.stringify(install(current, version))).toBe(JSON.stringify(current))
@@ -177,7 +182,11 @@ describe('Claude managed hook events by resolved version', () => {
const current = install({ hooks: {} }, '2.1.261')
expect(managedEvents(current)).toContain('SessionEnd')
const downgraded = install(current, '2.1.32')
const { claudeVersion } = readManagedHookDetectionResult({
agents: ['claude'],
versions: { claude: 'launcher 11.17.0\n2.1.32 (Claude Code)' }
})
const downgraded = install(current, claudeVersion ?? undefined)
for (const event of [
'StopFailure',
@@ -1,4 +1,9 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { runProcess } from '@orca/process-host'
import { readManagedHookDetectionResult } from '../agent-hooks/managed-hook-detection-commands'
import { getClaudeManagedHookPlan } from './claude-managed-hook-events'
vi.mock('@orca/process-host', () => ({ runProcess: vi.fn() }))
import { compareAppVersions } from '../../shared/app-version'
import fixture from './__fixtures__/claude-hook-event-enums.json'
import {
@@ -7,6 +12,8 @@ import {
claudeKnowsHookEvent,
claudeKnowsStatusLine,
parseClaudeCliVersion,
parseClaudeCliVersionOutput,
probeClaudeCliVersion,
UNRESOLVED_CLAUDE_VERSION,
type ClaudeHookEventName
} from './claude-hook-event-versions'
@@ -105,5 +112,66 @@ describe('parseClaudeCliVersion', () => {
it('extracts Claude Code version output', () => {
expect(parseClaudeCliVersion('2.1.261 (Claude Code)')).toBe('2.1.261')
expect(parseClaudeCliVersion('unknown')).toBeNull()
expect(parseClaudeCliVersion('2.1.261')).toBe('2.1.261')
})
})
describe('identified Claude version output', () => {
// Recorded macOS 2.1.310 / Windows 2.1.221 probes; historical releases' CLI source
// registers the same suffix (1.0.23, 1.0.64, 2.0.0 and 2.1.100).
it.each([
['2.1.310 (Claude Code)\n', '', '2.1.310', 15, 0],
['2.1.221 (Claude Code)\n', '', '2.1.221', 14, 1],
['1.0.23 (Claude Code)', '', '1.0.23', 2, 13],
['1.0.64 (Claude Code)', '', '1.0.64', 6, 9],
['2.0.0 (Claude Code)', '', '2.0.0', 6, 9],
['2.1.100 (Claude Code)', '', '2.1.100', 14, 1],
['launcher 11.17.0\n1.0.64 (Claude Code)', '', '1.0.64', 6, 9],
['launcher 0.1.0\n2.1.310 (Claude Code)', '', '2.1.310', 15, 0],
['launcher 0.1.0', '2.1.310 (Claude Code)\n', '2.1.310', 15, 0],
['', '2.1.310 (Claude Code)\n', '2.1.310', 15, 0],
['2.1.94-rc.1+build.2 (Claude Code)', '', '2.1.94-rc.1+build.2', 14, 1],
['11.17.0\n', '', null, 6, 0],
['2.30.0', '', null, 6, 0],
['2.1.310\nwrapper for (Claude Code)', '', null, 6, 0],
['2.1.310\n(Claude Code)', '', null, 6, 0],
['2.1 (Claude Code)', '', null, 6, 0]
] as const)(
'uses the identified version and hook plan for stdout=%s stderr=%s',
async (stdout, stderr, expected, installs, retirements) => {
vi.mocked(runProcess).mockResolvedValue({
stdout,
stderr,
code: 0,
signal: null,
timedOut: false
})
const local = await probeClaudeCliVersion('/bin/claude')
// Older guests send combined raw output, not canonical stored semver.
const remote = readManagedHookDetectionResult({
agents: ['claude'],
versions: { claude: `${stdout}\n${stderr}` }
})
expect(remote.agents).toEqual(['claude'])
expect(parseClaudeCliVersionOutput(`${stdout}\n${stderr}`)).toBe(expected)
for (const version of [local, remote.claudeVersion]) {
expect(version).toBe(expected)
const plan = getClaudeManagedHookPlan(version)
expect(plan.install).toHaveLength(installs)
expect(plan.retire).toHaveLength(retirements)
expect(plan.statusLine).toBe(expected === '1.0.23' ? 'retire' : 'install')
}
}
)
it('does not accept marked output from a failed probe', async () => {
vi.mocked(runProcess).mockResolvedValue({
stdout: '2.1.310 (Claude Code)\n',
stderr: '',
code: 1,
signal: null,
timedOut: false
})
expect(await probeClaudeCliVersion('/bin/claude')).toBeNull()
})
})
+12 -1
View File
@@ -38,6 +38,15 @@ export function parseClaudeCliVersion(output: string | null | undefined): string
return version && isValidAppVersion(version) ? version : null
}
// A launcher in Command (`npx claude`, `op run -- claude`) answers `--version` with its own
// version; only Claude's own `x.y.z (Claude Code)` line may pick the hook events.
export function parseClaudeCliVersionOutput(output: string | null | undefined): string | null {
const version = output?.match(
/\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)[ \t]+\(Claude Code\)/i
)?.[1]
return version && isValidAppVersion(version) ? version : null
}
export function claudeVersionReaches(version: string | null | undefined, floor: string): boolean {
const parsed = parseClaudeCliVersion(version)
return parsed !== null && hasReachedAppVersion(parsed, floor)
@@ -95,7 +104,9 @@ export async function probeClaudeCliVersion(
timeoutMs: launch?.timeoutMs ?? 5_000,
maxOutputBytes: 4_096
})
return result.code === 0 ? parseClaudeCliVersion(`${result.stdout}\n${result.stderr}`) : null
return result.code === 0
? parseClaudeCliVersionOutput(`${result.stdout}\n${result.stderr}`)
: null
} catch {
return null
}
@@ -1,5 +1,14 @@
import { describe, expect, it, vi } from 'vitest'
import { setClaudeStructuredOption } from './claude-structured-options'
import { runProcess } from '@orca/process-host'
import { probeClaudeCliVersion } from './claude-hook-event-versions'
import {
CLAUDE_APPLY_FLAG_SETTINGS_CONTROL,
CLAUDE_EFFORT_FLAG,
createClaudeCliFlagSupport
} from './claude-cli-flag-support'
import { resolveClaudeLaunchFlags } from './claude-structured-launch-flags'
import { claudeStructuredSpawnOptions } from './claude-structured-spawn-options'
import { claudeTakesFlagSettings, setClaudeStructuredOption } from './claude-structured-options'
import { applyClaudeMessageOptions } from './claude-structured-message-options'
import type { ClaudeSession } from './structured/claude-structured-session-state'
import { ClaudeControlRequestTimeoutError } from './claude-agent-sdk-control-requests'
@@ -9,11 +18,14 @@ import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog'
import { createClaudeInitProof } from './structured/claude-structured-init-proof'
import { claudeProviderHandleLink } from './structured/claude-structured-owner-identity'
import {
claudeStructuredSessionOptionsFrom,
observeClaudeFastModeFacts,
readClaudeStructuredSessionOptions,
refreshClaudeStructuredSessionOptions
} from './claude-structured-session-options'
vi.mock('@orca/process-host', () => ({ runProcess: vi.fn() }))
function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSession {
return {
connection: {
@@ -62,6 +74,71 @@ function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSe
}
}
describe('identified versions across native Claude consumers', () => {
it.each([
['launcher 0.1.0\n2.1.310 (Claude Code)', 'supported'],
['launcher 11.17.0\n1.0.64 (Claude Code)', 'unsupported'],
['11.17.0', 'unknown']
] as const)(
'derives launch flags, saved options and live controls from %s',
async (stdout, fact) => {
vi.mocked(runProcess).mockResolvedValue({
stdout,
stderr: '',
code: 0,
signal: null,
timedOut: false
})
const support = createClaudeCliFlagSupport({
probe: probeClaudeCliVersion,
keyOf: async () => 'binary',
budgetMs: 1_000,
now: () => performance.now()
})
const launch = { command: '/bin/launcher', prefixArgs: ['claude'], cwd: '/repo', env: {} }
const flags = await resolveClaudeLaunchFlags(
{
cliFlags: support,
prepareVisuals: async () => ({
folder: '/scratch',
skill: { pluginDir: '/plugin', skillsRoot: '/skills' }
})
},
'session',
launch
)
expect(flags.thinkingDisplayArgs).toEqual(
fact === 'supported' ? { 'thinking-display': 'summarized' } : {}
)
expect(flags.visuals?.pluginDir).toBe(fact === 'supported' ? '/plugin' : null)
const effortFlag = await support.check(CLAUDE_EFFORT_FLAG, launch)
const control = await support.check(CLAUDE_APPLY_FLAG_SETTINGS_CONTROL, launch)
expect(effortFlag).toBe(fact)
expect(control).toBe(fact)
const spawn = claudeStructuredSpawnOptions({
launch: { options: {}, resumesTranscript: false },
saved: { effort: 'high', fastMode: 'true' },
effortFlag,
flagSettingsControl: control
})
expect(spawn.sdkOptions.effort).toBe(fact === 'supported' ? 'high' : undefined)
expect(spawn.fastModeAtStart).toBe(fact !== 'unsupported')
const { session, applyFlagSettings } = fastModeSession(true)
session.cliFlag = (flag, budget, start) => support.check(flag, launch, budget, start)
expect(await claudeTakesFlagSettings(session)).toBe(fact !== 'unsupported')
expect(session.flagSettingsControl).toBe(fact)
const options = claudeStructuredSessionOptionsFrom(session, null)
expect(options.effortFixed).toBe(fact === 'unsupported' ? true : undefined)
if (fact !== 'unsupported') {
await setClaudeStructuredOption(session, { key: 'fastMode', value: 'false' }, undefined)
expect(applyFlagSettings).toHaveBeenCalled()
} else {
expect(options.fastModeSupport).toEqual({ supported: false, reason: 'cli-update-required' })
}
}
)
})
describe('Claude structured option mutation fencing', () => {
it('does not let a delayed earlier apply overwrite a later option', async () => {
let releaseFirst!: () => void