fix(agent-hooks): skip unavailable agent homes (#11442)

* fix(agent-hooks): skip unavailable agent homes

* refactor(agent-hooks): separate Pi and OMP home fix

* test(agent-hooks): update merged protocol harnesses

* fix(agent-hooks): avoid redundant reconciliation

* fix(agent-hooks): harden reconciliation and detection

* test(agent-hooks): cover settings reconciliation

* fix(agent-hooks): hydrate PATH for paired clients
This commit is contained in:
Brennan Benson
2026-07-29 20:19:18 -07:00
committed by GitHub
parent f0eca5fe32
commit f8b553b7d5
40 changed files with 1656 additions and 383 deletions
+3
View File
@@ -9,7 +9,9 @@
"../src/main/agent-hooks/hooks-json-read.ts",
"../src/main/agent-hooks/installer-utils.ts",
"../src/main/agent-hooks/installer-utils-remote.ts",
"../src/main/agent-hooks/local-agent-cli-presence.ts",
"../src/main/agent-hooks/managed-agent-hook-controls.ts",
"../src/main/agent-hooks/managed-agent-hook-registry.ts",
"../src/main/amp/hook-service.ts",
"../src/main/antigravity/hook-service.ts",
"../src/main/claude/hook-settings.ts",
@@ -68,6 +70,7 @@
"../src/main/kimi/kimi-hook-config-toml.ts",
"../src/main/openclaude/hook-service.ts",
"../src/main/rolling-file-backup.ts",
"../src/main/startup/hydrate-shell-path.ts",
"../src/main/runtime/runtime-metadata.ts",
"../src/main/win32-utils.ts"
],
+15 -5
View File
@@ -12,7 +12,7 @@ import {
} from '../runtime-client'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import { getDefaultPersistedState } from '../../shared/constants'
import type { PersistedState } from '../../shared/types'
import type { GlobalSettings, PersistedState } from '../../shared/types'
import {
applyAgentStatusHooksEnabled,
getManagedAgentHookStatuses
@@ -75,7 +75,10 @@ function readEnabledFromDisk(): boolean {
return state.settings?.agentStatusHooksEnabled !== false
}
function updateEnabledOnDisk(enabled: boolean): string {
function updateEnabledOnDisk(enabled: boolean): {
settingsPath: string
settings: Pick<GlobalSettings, 'agentCmdOverrides' | 'disabledTuiAgents'>
} {
const dataPath = getDataPath()
const state = readPersistedState(dataPath)
state.settings = {
@@ -84,7 +87,13 @@ function updateEnabledOnDisk(enabled: boolean): string {
agentStatusHooksEnabled: enabled
}
writePersistedState(dataPath, state)
return dataPath
return {
settingsPath: dataPath,
settings: {
agentCmdOverrides: state.settings.agentCmdOverrides ?? {},
disabledTuiAgents: state.settings.disabledTuiAgents ?? []
}
}
}
async function updateRunningRuntime(client: RuntimeClient, enabled: boolean): Promise<boolean> {
@@ -134,10 +143,11 @@ async function setAgentHooksEnabled(
enabled: boolean
): Promise<AgentHookCommandResult> {
const updatedRuntime = await updateRunningRuntime(client, enabled)
const settingsPath = updatedRuntime ? getDataPath() : updateEnabledOnDisk(enabled)
const offlineUpdate = updatedRuntime ? null : updateEnabledOnDisk(enabled)
const settingsPath = offlineUpdate?.settingsPath ?? getDataPath()
const statuses = updatedRuntime
? getManagedAgentHookStatuses()
: applyAgentStatusHooksEnabled(enabled)
: await applyAgentStatusHooksEnabled(enabled, offlineUpdate?.settings)
return {
enabled,
settingsPath,
+15 -131
View File
@@ -1,155 +1,39 @@
// Pins the contract between `runManagedHookInstallers` and the
// `agent_hook_install_failed` telemetry event: each catch must fire `track`
// with the correct agent label and a truncated error_message, and one
// installer's failure must not stop the others (fail-open semantics).
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { trackMock } = vi.hoisted(() => ({ trackMock: vi.fn() }))
vi.mock('../telemetry/client', () => ({ track: trackMock }))
import { runManagedHookInstallers } from './install-telemetry'
import { recordManagedHookInstallFailure } from './install-telemetry'
describe('runManagedHookInstallers', () => {
describe('recordManagedHookInstallFailure', () => {
beforeEach(() => {
trackMock.mockReset()
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
})
it('runs every installer when none throw and never calls track', () => {
const claude = vi.fn()
const codex = vi.fn()
runManagedHookInstallers([
['claude', claude],
['codex', codex]
])
expect(claude).toHaveBeenCalledTimes(1)
expect(codex).toHaveBeenCalledTimes(1)
expect(trackMock).not.toHaveBeenCalled()
})
it('fires agent_hook_install_failed with the correct agent label when an installer throws', () => {
runManagedHookInstallers([
[
'codex',
() => {
throw new Error('codex config malformed')
}
]
])
it('records the agent and truncated error message', () => {
recordManagedHookInstallFailure('codex', new Error('x'.repeat(500)))
expect(trackMock).toHaveBeenCalledTimes(1)
expect(trackMock).toHaveBeenCalledWith('agent_hook_install_failed', {
agent: 'codex',
error_message: 'codex config malformed'
})
})
it('continues running later installers after an earlier one throws (fail-open)', () => {
const codex = vi.fn()
const gemini = vi.fn()
runManagedHookInstallers([
[
'claude',
() => {
throw new Error('claude failed')
}
],
['codex', codex],
['gemini', gemini]
])
expect(codex).toHaveBeenCalledTimes(1)
expect(gemini).toHaveBeenCalledTimes(1)
expect(trackMock).toHaveBeenCalledTimes(1)
expect(trackMock).toHaveBeenCalledWith(
'agent_hook_install_failed',
expect.objectContaining({ agent: 'claude' })
)
})
it('truncates error_message to 200 chars', () => {
const longMessage = 'x'.repeat(500)
runManagedHookInstallers([
[
'gemini',
() => {
throw new Error(longMessage)
}
]
])
expect(trackMock).toHaveBeenCalledTimes(1)
const [, props] = trackMock.mock.calls[0] as [string, { error_message: string }]
expect(props.error_message.length).toBe(200)
})
it('handles non-Error throws', () => {
runManagedHookInstallers([
[
'cursor',
() => {
throw 'cursor string failure'
}
]
])
expect(trackMock).toHaveBeenCalledWith('agent_hook_install_failed', {
agent: 'cursor',
error_message: 'cursor string failure'
})
})
it('serializes thrown objects through JSON.stringify', () => {
runManagedHookInstallers([
[
'cursor',
() => {
throw { code: 'EACCES', path: '/tmp' }
}
]
])
expect(trackMock).toHaveBeenCalledTimes(1)
expect(trackMock).toHaveBeenCalledWith('agent_hook_install_failed', {
agent: 'cursor',
error_message: '{"code":"EACCES","path":"/tmp"}'
})
})
it('does not throw when an installer throws undefined (regression for JSON.stringify undefined return)', () => {
expect(() =>
runManagedHookInstallers([
[
'cursor',
() => {
throw undefined
}
]
])
).not.toThrow()
expect(trackMock).toHaveBeenCalledTimes(1)
const [eventName, props] = trackMock.mock.calls[0] as [string, { error_message: string }]
const [eventName, props] = trackMock.mock.calls[0] as [
string,
{ agent: string; error_message: string }
]
expect(eventName).toBe('agent_hook_install_failed')
expect(typeof props.error_message).toBe('string')
expect(props.agent).toBe('codex')
expect(props.error_message).toHaveLength(200)
})
it('continues running later installers when track itself throws (telemetry must not break fail-open)', () => {
const codex = vi.fn()
it('handles non-Error values and telemetry failures', () => {
trackMock.mockImplementationOnce(() => {
throw new Error('telemetry blew up')
throw new Error('telemetry failed')
})
expect(() =>
runManagedHookInstallers([
[
'claude',
() => {
throw new Error('claude failed')
}
],
['codex', codex]
])
).not.toThrow()
expect(codex).toHaveBeenCalledTimes(1)
expect(() => recordManagedHookInstallFailure('cursor', { code: 'EACCES' })).not.toThrow()
})
})
+8 -31
View File
@@ -1,21 +1,8 @@
// Per-agent managed-hook installer with fail-open semantics and PostHog
// attribution. Lifted out of `src/main/index.ts` so the loop is unit-testable
// without standing up the full Electron startup graph — the catch site needs
// the agent label to fire `agent_hook_install_failed`, and the previous
// closure-style loop lost it.
import type { HookInstallAgent } from '../../shared/telemetry-events'
import { track } from '../telemetry/client'
// Why: install errors are about config-file shape (malformed JSON, ACL
// denial), not user content — but messages can include paths or stack
// fragments. The 200-char cap matches `agentHookInstallFailedSchema.error_message`
// in `src/shared/telemetry-events.ts`; the validator drops overlength values,
// so truncation must happen here at the call site.
const ERROR_MESSAGE_MAX_LEN = 200
export type ManagedHookInstaller = readonly [HookInstallAgent, () => void]
function describeError(error: unknown): string {
if (error instanceof Error) {
return error.message
@@ -31,23 +18,13 @@ function describeError(error: unknown): string {
}
}
export function runManagedHookInstallers(installers: readonly ManagedHookInstaller[]): void {
for (const [agent, install] of installers) {
try {
install()
} catch (error) {
console.error(`[agent-hooks] Failed to install ${agent} managed hooks:`, error)
// Why: telemetry must not break fail-open. A throw inside `track` (e.g.
// a corrupted settings store the resolveConsent path reads from) would
// otherwise abort the for-loop and skip later agents' installers.
try {
track('agent_hook_install_failed', {
agent,
error_message: describeError(error).slice(0, ERROR_MESSAGE_MAX_LEN)
})
} catch (telemetryError) {
console.error('[agent-hooks] Failed to record install-failure telemetry:', telemetryError)
}
}
export function recordManagedHookInstallFailure(agent: HookInstallAgent, error: unknown): void {
try {
track('agent_hook_install_failed', {
agent,
error_message: describeError(error).slice(0, ERROR_MESSAGE_MAX_LEN)
})
} catch (telemetryError) {
console.error('[agent-hooks] Failed to record install-failure telemetry:', telemetryError)
}
}
@@ -0,0 +1,212 @@
import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ManagedAgentHookTarget } from '../../shared/managed-agent-hook-targets'
import { detectLocalManagedAgentCliPresence } from './local-agent-cli-presence'
const codexTarget: ManagedAgentHookTarget = {
agent: 'codex',
tuiAgent: 'codex',
executableCandidates: ['codex']
}
const claudeTarget: ManagedAgentHookTarget = {
agent: 'claude',
tuiAgent: 'claude',
executableCandidates: ['claude']
}
describe('detectLocalManagedAgentCliPresence', () => {
let tmpDir: string | null = null
afterEach(() => {
if (tmpDir) {
rmSync(tmpDir, { recursive: true, force: true })
tmpDir = null
}
})
it('scans a deduped PATH once for all agent candidates', async () => {
const probe = vi.fn(async (filePath: string) => filePath.endsWith('/bin/codex'))
const result = await detectLocalManagedAgentCliPresence(
[codexTarget, claudeTarget],
{ agentCmdOverrides: {} },
{
pathEnv: ['/bin', '/bin', '/usr/bin'].join(':'),
pathDelimiter: ':',
fileProbe: { isExecutableFile: probe },
platform: 'linux'
}
)
expect(result.codex?.state).toBe('found')
expect(result.claude?.state).toBe('missing')
expect(probe.mock.calls.map(([filePath]) => filePath)).toEqual([
'/bin/codex',
'/bin/claude',
'/usr/bin/claude'
])
})
it('uses executable override paths as positive evidence', async () => {
const probe = vi.fn(async (filePath: string) => filePath === '/custom/bin/codex')
const result = await detectLocalManagedAgentCliPresence(
[codexTarget],
{ agentCmdOverrides: { codex: '/custom/bin/codex --profile work' } },
{
pathEnv: '',
pathDelimiter: ':',
fileProbe: { isExecutableFile: probe },
platform: 'linux'
}
)
expect(result.codex?.state).toBe('found')
expect(probe).toHaveBeenCalledWith('/custom/bin/codex')
})
it('preserves Windows override separators', async () => {
const overridePath = 'C:\\My Tools\\claude.cmd'
const probe = vi.fn(async (filePath: string) => filePath === overridePath)
const result = await detectLocalManagedAgentCliPresence(
[claudeTarget],
{ agentCmdOverrides: { claude: `"${overridePath}" --flag` } },
{
pathEnv: '',
pathDelimiter: ';',
fileProbe: { isExecutableFile: probe },
platform: 'win32'
}
)
expect(result.claude?.state).toBe('found')
expect(probe).toHaveBeenCalledWith(overridePath)
})
it('expands Windows home-relative override paths with Windows separators', async () => {
const overridePath = 'C:\\Users\\orca\\bin\\claude.cmd'
const probe = vi.fn(async (filePath: string) => filePath === overridePath)
const result = await detectLocalManagedAgentCliPresence(
[claudeTarget],
{ agentCmdOverrides: { claude: '~\\bin\\claude.cmd --flag' } },
{
pathEnv: '',
fileProbe: { isExecutableFile: probe },
platform: 'win32',
homeDir: 'C:\\Users\\orca'
}
)
expect(result.claude?.state).toBe('found')
expect(probe).toHaveBeenCalledWith(overridePath)
})
it('expands home-relative override paths', async () => {
const probe = vi.fn(async (filePath: string) => filePath === '/home/orca/bin/codex')
const result = await detectLocalManagedAgentCliPresence(
[codexTarget],
{ agentCmdOverrides: { codex: '~/bin/codex --profile work' } },
{
pathEnv: '',
pathDelimiter: ':',
fileProbe: { isExecutableFile: probe },
platform: 'linux',
homeDir: '/home/orca'
}
)
expect(result.codex?.state).toBe('found')
expect(probe).toHaveBeenCalledWith('/home/orca/bin/codex')
})
it('reports relative override paths as unknown', async () => {
const probe = vi.fn(async () => true)
const result = await detectLocalManagedAgentCliPresence(
[codexTarget],
{ agentCmdOverrides: { codex: 'bin/codex --profile work' } },
{
pathEnv: '',
pathDelimiter: ':',
fileProbe: { isExecutableFile: probe },
platform: 'linux'
}
)
expect(result.codex).toEqual({ state: 'unknown' })
expect(probe).not.toHaveBeenCalled()
})
it('honors PATHEXT for Windows PATH candidates', async () => {
const probe = vi.fn(async (filePath: string) => filePath === 'C:\\Tools\\codex.CMD')
const result = await detectLocalManagedAgentCliPresence(
[codexTarget],
{ agentCmdOverrides: {} },
{
pathEnv: 'C:\\Other;C:\\Tools',
pathExt: '.EXE;.CMD',
fileProbe: { isExecutableFile: probe },
platform: 'win32'
}
)
expect(result.codex?.state).toBe('found')
expect(probe.mock.calls.map(([filePath]) => filePath)).toEqual([
'C:\\Other\\codex.EXE',
'C:\\Other\\codex.CMD',
'C:\\Tools\\codex.EXE',
'C:\\Tools\\codex.CMD'
])
})
it('warns and uses the inherited PATH when shell hydration throws', async () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const result = await detectLocalManagedAgentCliPresence(
[codexTarget],
{ agentCmdOverrides: {} },
{
pathEnv: '',
pathDelimiter: ':',
fileProbe: { isExecutableFile: vi.fn(async () => false) },
platform: 'linux',
shouldHydrateShellPath: true,
hydratePath: vi.fn(async () => {
throw new Error('shell unavailable')
})
}
)
expect(result.codex?.state).toBe('missing')
expect(warning).toHaveBeenCalledWith(
'[agent-hooks] Shell PATH hydration failed; using inherited PATH:',
expect.objectContaining({ message: 'shell unavailable' })
)
} finally {
warning.mockRestore()
}
})
it.runIf(process.platform !== 'win32')(
'accepts executable symlinks and rejects broken symlinks',
async () => {
tmpDir = mkdtempSync(join(tmpdir(), 'orca-cli-presence-'))
const binDir = join(tmpDir, 'bin')
mkdirSync(binDir)
const targetPath = join(tmpDir, 'codex-real')
writeFileSync(targetPath, '#!/bin/sh\n')
chmodSync(targetPath, 0o755)
symlinkSync(targetPath, join(binDir, 'codex'))
symlinkSync(join(tmpDir, 'missing'), join(binDir, 'claude'))
const result = await detectLocalManagedAgentCliPresence(
[codexTarget, claudeTarget],
{ agentCmdOverrides: {} },
{ pathEnv: binDir, pathDelimiter: ':', platform: process.platform }
)
expect(result.codex?.state).toBe('found')
expect(result.claude?.state).toBe('missing')
}
)
})
@@ -0,0 +1,200 @@
import { constants } from 'node:fs'
import { access, stat } from 'node:fs/promises'
import { homedir } from 'node:os'
import path from 'node:path'
import type { AgentHookTarget } from '../../shared/agent-hook-types'
import {
extractExecutableToken,
hasPathSeparatorToken,
isSafeExecutableBasename
} from '../../shared/managed-agent-command-token'
import type { ManagedAgentHookTarget } from '../../shared/managed-agent-hook-targets'
import type { GlobalSettings } from '../../shared/types'
import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path'
export type LocalCliPresenceState = 'found' | 'missing' | 'unknown'
export type LocalCliPresenceByAgent = Partial<
Record<AgentHookTarget, { state: LocalCliPresenceState }>
>
type FileProbe = {
isExecutableFile: (filePath: string) => Promise<boolean>
}
type HydrationResult =
| { ok: true; segments: string[] }
| { ok: false; segments: []; failureReason?: string }
type DetectOptions = {
pathEnv?: string
platform?: NodeJS.Platform
pathDelimiter?: string
pathExt?: string
fileProbe?: FileProbe
hydratePath?: () => Promise<HydrationResult>
shouldHydrateShellPath?: boolean
homeDir?: string
}
type CommandOverrideSettings = Partial<Pick<GlobalSettings, 'agentCmdOverrides'>> | null | undefined
const DEFAULT_WINDOWS_EXTENSIONS = ['.COM', '.EXE', '.BAT', '.CMD']
function pathApiForPlatform(platform: NodeJS.Platform) {
return platform === 'win32' ? path.win32 : path.posix
}
async function isExecutableFile(filePath: string, platform: NodeJS.Platform): Promise<boolean> {
try {
const fileStat = await stat(filePath)
if (!fileStat.isFile()) {
return false
}
if (platform === 'win32') {
return true
}
await access(filePath, constants.X_OK)
return true
} catch {
return false
}
}
function pathEntries(pathEnv: string, delimiter: string): string[] {
return [...new Set(pathEnv.split(delimiter).filter(Boolean))]
}
function windowsPathExts(value: string | undefined): string[] {
const source = value?.length ? value : DEFAULT_WINDOWS_EXTENSIONS.join(';')
return [
...new Set(
source
.split(';')
.map((part) => part.trim())
.filter(Boolean)
.map((part) => (part.startsWith('.') ? part : `.${part}`))
.map((part) => part.toUpperCase())
)
]
}
function candidateFileNames(
candidate: string,
platform: NodeJS.Platform,
pathExt?: string
): string[] {
if (platform !== 'win32' || pathApiForPlatform(platform).extname(candidate)) {
return [candidate]
}
return windowsPathExts(pathExt).map((suffix) => `${candidate}${suffix}`)
}
function overrideTokenForAgent(
settings: CommandOverrideSettings,
target: ManagedAgentHookTarget,
platform: NodeJS.Platform
): string | null {
return extractExecutableToken(settings?.agentCmdOverrides?.[target.tuiAgent], { platform })
}
function expandHomePathToken(token: string, platform: NodeJS.Platform, homeDir: string): string {
if (token === '~') {
return homeDir
}
if (token.startsWith('~/') || (platform === 'win32' && token.startsWith('~\\'))) {
return pathApiForPlatform(platform).join(homeDir, token.slice(2))
}
return token
}
async function probePathCandidate(
candidate: string,
dirs: readonly string[],
platform: NodeJS.Platform,
fileProbe: FileProbe,
pathExt?: string
): Promise<boolean> {
if (!isSafeExecutableBasename(candidate)) {
return false
}
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 false
}
function isPlatformAbsolutePath(candidate: string, platform: NodeJS.Platform): boolean {
return platform === 'win32' ? path.win32.isAbsolute(candidate) : path.posix.isAbsolute(candidate)
}
async function maybeHydrateShellPath(options: DetectOptions): Promise<void> {
if (!options.shouldHydrateShellPath) {
return
}
try {
const result = await (options.hydratePath ?? hydrateShellPath)()
if (result.ok) {
mergePathSegments(result.segments)
}
} catch (error) {
// Detection failure must never permit config mutation.
console.warn('[agent-hooks] Shell PATH hydration failed; using inherited PATH:', error)
}
}
export async function detectLocalManagedAgentCliPresence(
targets: readonly ManagedAgentHookTarget[],
settings: CommandOverrideSettings,
options: DetectOptions = {}
): Promise<LocalCliPresenceByAgent> {
await maybeHydrateShellPath(options)
const platform = options.platform ?? process.platform
const delimiter = options.pathDelimiter ?? pathApiForPlatform(platform).delimiter
const dirs = pathEntries(options.pathEnv ?? process.env.PATH ?? '', delimiter)
const homeDir = options.homeDir ?? homedir()
const fileProbe = options.fileProbe ?? {
isExecutableFile: (filePath: string) => isExecutableFile(filePath, platform)
}
const candidates = new Set<string>()
for (const target of targets) {
for (const candidate of target.executableCandidates) {
if (!hasPathSeparatorToken(candidate)) {
candidates.add(candidate)
}
}
const override = overrideTokenForAgent(settings, target, platform)
if (override && !hasPathSeparatorToken(override)) {
candidates.add(override)
}
}
const found = new Set<string>()
for (const candidate of candidates) {
if (await probePathCandidate(candidate, dirs, platform, fileProbe, options.pathExt)) {
found.add(candidate)
}
}
const result: LocalCliPresenceByAgent = {}
for (const target of targets) {
const override = overrideTokenForAgent(settings, target, platform)
if (override && hasPathSeparatorToken(override)) {
const expanded = expandHomePathToken(override, platform, homeDir)
if (!isPlatformAbsolutePath(expanded, platform)) {
result[target.agent] = { state: 'unknown' }
continue
}
result[target.agent] = (await fileProbe.isExecutableFile(expanded))
? { state: 'found' }
: { state: 'missing' }
continue
}
const targetCandidates = [...target.executableCandidates, ...(override ? [override] : [])]
result[target.agent] = targetCandidates.some((candidate) => found.has(candidate))
? { state: 'found' }
: { state: 'missing' }
}
return result
}
@@ -0,0 +1,151 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
detect: vi.fn(),
installClaude: vi.fn(),
installCodex: vi.fn(),
removeClaude: vi.fn(),
removeCodex: vi.fn(),
statusClaude: vi.fn(),
statusCodex: vi.fn()
}))
vi.mock('./local-agent-cli-presence', () => ({
detectLocalManagedAgentCliPresence: mocks.detect
}))
vi.mock('./managed-agent-hook-registry', () => ({
MANAGED_AGENT_HOOK_INSTALLERS: [
['claude', mocks.installClaude],
['codex', mocks.installCodex]
],
MANAGED_AGENT_HOOK_REMOVERS: [
['claude', mocks.removeClaude],
['codex', mocks.removeCodex]
],
MANAGED_AGENT_HOOK_STATUS_READERS: [
['claude', mocks.statusClaude],
['codex', mocks.statusCodex]
]
}))
import {
applyAgentStatusHooksEnabled,
installManagedAgentHooks
} from './managed-agent-hook-controls'
function status(agent: 'claude' | 'codex', state: 'installed' | 'not_installed') {
return {
agent,
state,
configPath: `/${agent}`,
managedHooksPresent: state === 'installed',
detail: null
} as const
}
describe('managed agent hook controls', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.installClaude.mockReturnValue(status('claude', 'installed'))
mocks.installCodex.mockReturnValue(status('codex', 'installed'))
mocks.removeClaude.mockReturnValue(status('claude', 'not_installed'))
mocks.removeCodex.mockReturnValue(status('codex', 'not_installed'))
})
it('installs only agents with positively detected CLIs', async () => {
mocks.detect.mockResolvedValue({
claude: { state: 'missing' },
codex: { state: 'found' }
})
const results = await installManagedAgentHooks({ agentCmdOverrides: {} })
expect(mocks.installClaude).not.toHaveBeenCalled()
expect(mocks.installCodex).toHaveBeenCalledTimes(1)
expect(results).toEqual([
expect.objectContaining({
agent: 'claude',
state: 'skipped',
skipReason: 'cli_not_found'
}),
expect.objectContaining({ agent: 'codex', state: 'installed' })
])
})
it('fails closed when CLI detection rejects', async () => {
mocks.detect.mockRejectedValue(new Error('detection unavailable'))
const results = await installManagedAgentHooks({ agentCmdOverrides: {} })
expect(mocks.installClaude).not.toHaveBeenCalled()
expect(mocks.installCodex).not.toHaveBeenCalled()
expect(results).toEqual([
expect.objectContaining({
agent: 'claude',
state: 'skipped',
skipReason: 'cli_presence_unknown'
}),
expect.objectContaining({
agent: 'codex',
state: 'skipped',
skipReason: 'cli_presence_unknown'
})
])
})
it('removes disabled agents without probing or reinstalling them', async () => {
mocks.detect.mockResolvedValue({ codex: { state: 'found' } })
const results = await applyAgentStatusHooksEnabled(true, {
agentCmdOverrides: {},
disabledTuiAgents: ['claude']
})
expect(mocks.removeClaude).toHaveBeenCalledTimes(1)
expect(mocks.installClaude).not.toHaveBeenCalled()
expect(mocks.installCodex).toHaveBeenCalledTimes(1)
expect(results).toEqual([
expect.objectContaining({ agent: 'claude', state: 'not_installed' }),
expect.objectContaining({ agent: 'codex', state: 'installed' })
])
})
it('does not install an agent disabled while detection was running', async () => {
mocks.detect.mockResolvedValue({
claude: { state: 'found' },
codex: { state: 'found' }
})
await installManagedAgentHooks(
{ agentCmdOverrides: {} },
{ shouldContinue: (agent) => agent !== 'claude' }
)
expect(mocks.installClaude).not.toHaveBeenCalled()
expect(mocks.installCodex).toHaveBeenCalledTimes(1)
})
it('does not remove an agent enabled by a newer settings update', async () => {
mocks.detect.mockResolvedValue({ codex: { state: 'found' } })
await applyAgentStatusHooksEnabled(
true,
{
agentCmdOverrides: {},
disabledTuiAgents: ['claude']
},
{ shouldContinue: () => true }
)
expect(mocks.removeClaude).not.toHaveBeenCalled()
})
it('removes every managed hook when the global setting is off', async () => {
await applyAgentStatusHooksEnabled(false)
expect(mocks.detect).not.toHaveBeenCalled()
expect(mocks.removeClaude).toHaveBeenCalledTimes(1)
expect(mocks.removeCodex).toHaveBeenCalledTimes(1)
})
})
@@ -1,75 +1,34 @@
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import type { HookInstallAgent } from '../../shared/telemetry-events'
import type { AgentHookInstallStatus, AgentHookTarget } from '../../shared/agent-hook-types'
import {
getManagedAgentHookTarget,
isManagedAgentHookTarget
} from '../../shared/managed-agent-hook-targets'
import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection'
import type { GlobalSettings } from '../../shared/types'
import { ampHookService } from '../amp/hook-service'
import { antigravityHookService } from '../antigravity/hook-service'
import { claudeHookService } from '../claude/hook-service'
import { codexHookService } from '../codex/hook-service'
import { copilotHookService } from '../copilot/hook-service'
import { cursorHookService } from '../cursor/hook-service'
import { droidHookService } from '../droid/hook-service'
import { commandCodeHookService } from '../command-code/hook-service'
import { geminiHookService } from '../gemini/hook-service'
import { devinHookService } from '../devin/hook-service'
import { grokHookService } from '../grok/hook-service'
import { hermesHookService } from '../hermes/hook-service'
import { kimiHookService } from '../kimi/hook-service'
import { openClaudeHookService } from '../openclaude/hook-service'
import { detectLocalManagedAgentCliPresence } from './local-agent-cli-presence'
import {
MANAGED_AGENT_HOOK_INSTALLERS,
MANAGED_AGENT_HOOK_REMOVERS,
MANAGED_AGENT_HOOK_STATUS_READERS,
type ManagedAgentHookInstaller
} from './managed-agent-hook-registry'
export type ManagedAgentHookInstaller = readonly [HookInstallAgent, () => void]
type ManagedHookRemover = readonly [HookInstallAgent, () => AgentHookInstallStatus]
type ManagedHookStatusReader = readonly [HookInstallAgent, () => AgentHookInstallStatus]
export { MANAGED_AGENT_HOOK_INSTALLERS } from './managed-agent-hook-registry'
export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] = [
['claude', () => claudeHookService.install()],
['openclaude', () => openClaudeHookService.install()],
['codex', () => codexHookService.install()],
['gemini', () => geminiHookService.install()],
['antigravity', () => antigravityHookService.install()],
['amp', () => ampHookService.install()],
['cursor', () => cursorHookService.install()],
['droid', () => droidHookService.install()],
['command-code', () => commandCodeHookService.install()],
['grok', () => grokHookService.install()],
['copilot', () => copilotHookService.install()],
['hermes', () => hermesHookService.install()],
['devin', () => devinHookService.install()],
['kimi', () => kimiHookService.install()]
]
type ManagedHookSettings = Partial<
Pick<GlobalSettings, 'agentCmdOverrides' | 'disabledTuiAgents'>
> | null
const LOCAL_MANAGED_HOOK_REMOVERS: readonly ManagedHookRemover[] = [
['claude', () => claudeHookService.remove()],
['openclaude', () => openClaudeHookService.remove()],
['codex', () => codexHookService.remove()],
['gemini', () => geminiHookService.remove()],
['antigravity', () => antigravityHookService.remove()],
['amp', () => ampHookService.remove()],
['cursor', () => cursorHookService.remove()],
['droid', () => droidHookService.remove()],
['command-code', () => commandCodeHookService.remove()],
['grok', () => grokHookService.remove()],
['copilot', () => copilotHookService.remove()],
['hermes', () => hermesHookService.remove()],
['devin', () => devinHookService.remove()],
['kimi', () => kimiHookService.remove()]
]
type InstallOptions = {
shouldHydrateShellPath?: boolean
onInstallError?: (agent: AgentHookTarget, error: unknown) => void
shouldContinue?: (agent: AgentHookTarget) => boolean
agents?: readonly AgentHookTarget[]
}
const LOCAL_MANAGED_HOOK_STATUS_READERS: readonly ManagedHookStatusReader[] = [
['claude', () => claudeHookService.getStatus()],
['openclaude', () => openClaudeHookService.getStatus()],
['codex', () => codexHookService.getStatus()],
['gemini', () => geminiHookService.getStatus()],
['antigravity', () => antigravityHookService.getStatus()],
['amp', () => ampHookService.getStatus()],
['cursor', () => cursorHookService.getStatus()],
['droid', () => droidHookService.getStatus()],
['grok', () => grokHookService.getStatus()],
['command-code', () => commandCodeHookService.getStatus()],
['copilot', () => copilotHookService.getStatus()],
['hermes', () => hermesHookService.getStatus()],
['devin', () => devinHookService.getStatus()],
['kimi', () => kimiHookService.getStatus()]
]
type RemoveOptions = {
agents?: readonly AgentHookTarget[]
}
export function isAgentStatusHooksEnabled(
settings: Pick<GlobalSettings, 'agentStatusHooksEnabled'> | null | undefined
@@ -77,17 +36,7 @@ export function isAgentStatusHooksEnabled(
return settings?.agentStatusHooksEnabled !== false
}
export function installManagedAgentHooks(): void {
for (const [agent, install] of MANAGED_AGENT_HOOK_INSTALLERS) {
try {
install()
} catch (error) {
console.warn(`[agent-hooks] Failed to install ${agent} managed hooks:`, error)
}
}
}
function errorStatus(agent: HookInstallAgent, error: unknown): AgentHookInstallStatus {
function errorStatus(agent: AgentHookTarget, error: unknown): AgentHookInstallStatus {
return {
agent,
state: 'error',
@@ -97,8 +46,110 @@ function errorStatus(agent: HookInstallAgent, error: unknown): AgentHookInstallS
}
}
export function removeManagedAgentHooks(): AgentHookInstallStatus[] {
return LOCAL_MANAGED_HOOK_REMOVERS.map(([agent, remove]) => {
function skippedStatus(
agent: AgentHookTarget,
skipReason: NonNullable<AgentHookInstallStatus['skipReason']>,
detail: string
): AgentHookInstallStatus {
return {
agent,
state: 'skipped',
configPath: '',
managedHooksPresent: false,
detail,
skipReason
}
}
function selectedInstallers(options: InstallOptions): readonly ManagedAgentHookInstaller[] {
if (!options.agents) {
return MANAGED_AGENT_HOOK_INSTALLERS
}
const allowed = new Set(options.agents)
return MANAGED_AGENT_HOOK_INSTALLERS.filter(([agent]) => allowed.has(agent))
}
function runInstaller(
entry: ManagedAgentHookInstaller,
onInstallError: InstallOptions['onInstallError']
): AgentHookInstallStatus {
const [agent, install] = entry
try {
return install()
} catch (error) {
console.error(`[agent-hooks] Failed to install ${agent} managed hooks:`, error)
try {
onInstallError?.(agent, error)
} catch (telemetryError) {
console.error('[agent-hooks] Failed to record install-failure telemetry:', telemetryError)
}
return errorStatus(agent, error)
}
}
export async function installManagedAgentHooks(
settings: ManagedHookSettings = null,
options: InstallOptions = {}
): Promise<AgentHookInstallStatus[]> {
const installers = selectedInstallers(options)
const disabled = new Set(normalizeDisabledTuiAgents(settings?.disabledTuiAgents))
const enabledInstallers = installers.filter(([agent]) => !disabled.has(agent))
const targets = enabledInstallers.flatMap(([agent]) => {
const target = getManagedAgentHookTarget(agent)
return target ? [target] : []
})
let presenceByAgent
try {
presenceByAgent = await detectLocalManagedAgentCliPresence(targets, settings, {
shouldHydrateShellPath: options.shouldHydrateShellPath
})
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
return installers.map(([agent]) =>
disabled.has(agent)
? skippedStatus(agent, 'agent_disabled', 'Agent is disabled in Settings.')
: skippedStatus(agent, 'cli_presence_unknown', detail)
)
}
const results: AgentHookInstallStatus[] = []
for (const entry of installers) {
const [agent] = entry
if (disabled.has(agent)) {
results.push(skippedStatus(agent, 'agent_disabled', 'Agent is disabled in Settings.'))
continue
}
if (options.shouldContinue && !options.shouldContinue(agent)) {
results.push(
skippedStatus(
agent,
'hooks_disabled',
'Agent status hooks were disabled before install completed.'
)
)
continue
}
const presence = presenceByAgent[agent]
if (presence?.state !== 'found') {
results.push(
skippedStatus(
agent,
presence?.state === 'unknown' ? 'cli_presence_unknown' : 'cli_not_found',
'CLI not found; managed hook install skipped.'
)
)
continue
}
results.push(runInstaller(entry, options.onInstallError))
}
return results
}
export function removeManagedAgentHooks(options: RemoveOptions = {}): AgentHookInstallStatus[] {
const allowed = options.agents ? new Set(options.agents) : null
return MANAGED_AGENT_HOOK_REMOVERS.filter(
([agent]) => allowed === null || allowed.has(agent)
).map(([agent, remove]) => {
try {
return remove()
} catch (error) {
@@ -108,7 +159,7 @@ export function removeManagedAgentHooks(): AgentHookInstallStatus[] {
}
export function getManagedAgentHookStatuses(): AgentHookInstallStatus[] {
return LOCAL_MANAGED_HOOK_STATUS_READERS.map(([agent, getStatus]) => {
return MANAGED_AGENT_HOOK_STATUS_READERS.map(([agent, getStatus]) => {
try {
return getStatus()
} catch (error) {
@@ -117,10 +168,26 @@ export function getManagedAgentHookStatuses(): AgentHookInstallStatus[] {
})
}
export function applyAgentStatusHooksEnabled(enabled: boolean): AgentHookInstallStatus[] {
if (enabled) {
installManagedAgentHooks()
return getManagedAgentHookStatuses()
export async function applyAgentStatusHooksEnabled(
enabled: boolean,
settings: ManagedHookSettings = null,
options: InstallOptions = {}
): Promise<AgentHookInstallStatus[]> {
if (!enabled) {
return removeManagedAgentHooks()
}
return removeManagedAgentHooks()
const disabled = normalizeDisabledTuiAgents(settings?.disabledTuiAgents).filter(
isManagedAgentHookTarget
)
const installed = await installManagedAgentHooks(settings, options)
const disabledToRemove = options.shouldContinue
? disabled.filter((agent) => !options.shouldContinue?.(agent))
: disabled
if (disabledToRemove.length === 0) {
return installed
}
const removed = new Map(
removeManagedAgentHooks({ agents: disabledToRemove }).map((status) => [status.agent, status])
)
return installed.map((status) => removed.get(status.agent) ?? status)
}
@@ -0,0 +1,71 @@
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import type { HookInstallAgent } from '../../shared/telemetry-events'
import { ampHookService } from '../amp/hook-service'
import { antigravityHookService } from '../antigravity/hook-service'
import { claudeHookService } from '../claude/hook-service'
import { codexHookService } from '../codex/hook-service'
import { commandCodeHookService } from '../command-code/hook-service'
import { copilotHookService } from '../copilot/hook-service'
import { cursorHookService } from '../cursor/hook-service'
import { devinHookService } from '../devin/hook-service'
import { droidHookService } from '../droid/hook-service'
import { geminiHookService } from '../gemini/hook-service'
import { grokHookService } from '../grok/hook-service'
import { hermesHookService } from '../hermes/hook-service'
import { kimiHookService } from '../kimi/hook-service'
import { openClaudeHookService } from '../openclaude/hook-service'
export type ManagedAgentHookInstaller = readonly [HookInstallAgent, () => AgentHookInstallStatus]
export type ManagedAgentHookRemover = readonly [HookInstallAgent, () => AgentHookInstallStatus]
export type ManagedAgentHookStatusReader = readonly [HookInstallAgent, () => AgentHookInstallStatus]
export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] = [
['claude', () => claudeHookService.install()],
['openclaude', () => openClaudeHookService.install()],
['codex', () => codexHookService.install()],
['gemini', () => geminiHookService.install()],
['antigravity', () => antigravityHookService.install()],
['amp', () => ampHookService.install()],
['cursor', () => cursorHookService.install()],
['droid', () => droidHookService.install()],
['command-code', () => commandCodeHookService.install()],
['grok', () => grokHookService.install()],
['copilot', () => copilotHookService.install()],
['hermes', () => hermesHookService.install()],
['devin', () => devinHookService.install()],
['kimi', () => kimiHookService.install()]
]
export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [
['claude', () => claudeHookService.remove()],
['openclaude', () => openClaudeHookService.remove()],
['codex', () => codexHookService.remove()],
['gemini', () => geminiHookService.remove()],
['antigravity', () => antigravityHookService.remove()],
['amp', () => ampHookService.remove()],
['cursor', () => cursorHookService.remove()],
['droid', () => droidHookService.remove()],
['command-code', () => commandCodeHookService.remove()],
['grok', () => grokHookService.remove()],
['copilot', () => copilotHookService.remove()],
['hermes', () => hermesHookService.remove()],
['devin', () => devinHookService.remove()],
['kimi', () => kimiHookService.remove()]
]
export const MANAGED_AGENT_HOOK_STATUS_READERS: readonly ManagedAgentHookStatusReader[] = [
['claude', () => claudeHookService.getStatus()],
['openclaude', () => openClaudeHookService.getStatus()],
['codex', () => codexHookService.getStatus()],
['gemini', () => geminiHookService.getStatus()],
['antigravity', () => antigravityHookService.getStatus()],
['amp', () => ampHookService.getStatus()],
['cursor', () => cursorHookService.getStatus()],
['droid', () => droidHookService.getStatus()],
['grok', () => grokHookService.getStatus()],
['command-code', () => commandCodeHookService.getStatus()],
['copilot', () => copilotHookService.getStatus()],
['hermes', () => hermesHookService.getStatus()],
['devin', () => devinHookService.getStatus()],
['kimi', () => kimiHookService.getStatus()]
]
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import {
buildManagedHookDetectionCommands,
detectedManagedHookAgents
} from './managed-hook-detection-commands'
describe('managed hook detection commands', () => {
it('omits disabled agents and includes safe command overrides', () => {
const commands = buildManagedHookDetectionCommands(
{
disabledTuiAgents: ['claude'],
agentCmdOverrides: { codex: '/opt/codex custom' }
},
'linux'
)
expect(commands.some((command) => command.id === 'claude')).toBe(false)
expect(commands).toContainEqual({ id: 'codex', cmd: '/opt/codex' })
})
it('maps detected TUI ids back to managed hook targets', () => {
expect(detectedManagedHookAgents(['codex', 'opencode', 'droid'])).toEqual(['codex', 'droid'])
})
})
@@ -0,0 +1,42 @@
import type { AgentHookTarget } from '../../shared/agent-hook-types'
import {
extractExecutableToken,
isSafeOverrideExecutableToken
} from '../../shared/managed-agent-command-token'
import { MANAGED_AGENT_HOOK_TARGETS } from '../../shared/managed-agent-hook-targets'
import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection'
import type { GlobalSettings } from '../../shared/types'
import type { TuiAgentDetectionCommand } from '../ipc/tui-agent-detection-commands'
export type ManagedHookDetectionSettings = Partial<
Pick<GlobalSettings, 'agentCmdOverrides' | 'disabledTuiAgents'>
> | null
export function buildManagedHookDetectionCommands(
settings: ManagedHookDetectionSettings,
platform: NodeJS.Platform
): TuiAgentDetectionCommand[] {
const disabled = new Set(normalizeDisabledTuiAgents(settings?.disabledTuiAgents))
return MANAGED_AGENT_HOOK_TARGETS.filter((target) => !disabled.has(target.tuiAgent)).flatMap(
(target) => {
const commands = new Set(target.executableCandidates)
const override = extractExecutableToken(settings?.agentCmdOverrides?.[target.tuiAgent], {
platform
})
if (override && isSafeOverrideExecutableToken(override)) {
commands.add(override)
}
return [...commands].map((cmd) => ({ id: target.tuiAgent, cmd }))
}
)
}
export function detectedManagedHookAgents(values: unknown): AgentHookTarget[] {
if (!Array.isArray(values)) {
return []
}
const detected = new Set(values.filter((value): value is string => typeof value === 'string'))
return MANAGED_AGENT_HOOK_TARGETS.filter((target) => detected.has(target.tuiAgent)).map(
(target) => target.agent
)
}
@@ -6,9 +6,11 @@ import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers
import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem'
const tempHomes: string[] = []
const tempRoot = process.platform === 'win32' ? tmpdir() : '/tmp'
async function createTempHome(): Promise<string> {
const home = await mkdtemp(join(tmpdir(), 'orca-managed-hooks-'))
// Why: mkdir-p probes ancestors; macOS's per-user temp directory can contain hundreds of thousands of entries.
const home = await mkdtemp(join(tempRoot, 'orca-managed-hooks-'))
tempHomes.push(home)
return home
}
+4 -1
View File
@@ -3,6 +3,7 @@ import { basename } from 'node:path'
import { homedir, userInfo } from 'node:os'
import { promisify } from 'node:util'
import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
import type { AgentHookTarget } from '../../shared/agent-hook-types'
import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem'
import { withManagedHookInstallLock } from './managed-hook-install-lock'
import {
@@ -75,6 +76,7 @@ export async function resolveRelayGrokHome(home: string, signal?: AbortSignal):
export async function installManagedHooks(options?: {
signal?: AbortSignal
hostKeyFingerprint?: string
agents?: readonly AgentHookTarget[]
}): Promise<ManagedHookInstallSummary> {
options?.signal?.throwIfAborted()
const home = homedir()
@@ -93,7 +95,8 @@ export async function installManagedHooks(options?: {
home,
{
grokHomeDir,
signal: options?.signal
signal: options?.signal,
agents: options?.agents
}
)
return {
@@ -713,6 +713,20 @@ describe('remote hook service installers', () => {
expect(byAgent.get('copilot')).toBe('installed')
})
it('installs only positively detected remote agents', async () => {
const { sftp, fs } = createFakeSftp()
const results = await installRemoteManagedAgentHooks(sftp, '/home/dev', {
agents: ['codex']
})
expect(results.map((result) => result.agent)).toEqual(['codex'])
const paths = [...fs.files.keys(), ...fs.dirs]
for (const unusedHome of ['.factory', '.gemini', '.grok', '.hermes', '.commandcode']) {
expect(paths.some((path) => path.includes(`/home/dev/${unusedHome}`))).toBe(false)
}
})
it('stops before the next installer when its relay request is cancelled', async () => {
const controller = new AbortController()
const claudeInstall = vi
@@ -1,5 +1,5 @@
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import type { AgentHookInstallStatus, AgentHookTarget } from '../../shared/agent-hook-types'
import { ampHookService } from '../amp/hook-service'
import { claudeHookService } from '../claude/hook-service'
import { codexHookService } from '../codex/hook-service'
@@ -26,6 +26,8 @@ export type RemoteManagedHookInstallOptions = {
/** Stops before starting the next installer when the owning relay request
* is cancelled. Individual filesystem mutations remain atomic. */
signal?: AbortSignal
/** Positively detected and enabled agents allowed to mutate config. */
agents?: readonly AgentHookTarget[]
}
type RemoteManagedHookInstaller = readonly [
@@ -80,7 +82,11 @@ export async function installRemoteManagedAgentHooks(
options?: RemoteManagedHookInstallOptions
): Promise<AgentHookInstallStatus[]> {
const results: AgentHookInstallStatus[] = []
const allowedAgents = options?.agents ? new Set(options.agents) : null
for (const [agent, install] of REMOTE_MANAGED_HOOK_INSTALLERS) {
if (allowedAgents && !allowedAgents.has(agent)) {
continue
}
// Why: relay requests can disappear during reconnect; do not start more
// user-config mutations after their client has gone away.
options?.signal?.throwIfAborted()
+26 -2
View File
@@ -6,6 +6,11 @@
import type { SFTPWrapper } from 'ssh2'
import type { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
import {
buildManagedHookDetectionCommands,
detectedManagedHookAgents,
type ManagedHookDetectionSettings
} from './managed-hook-detection-commands'
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env'
import { WSL_HOOK_FS_METHODS, type WslFsResult } from '../../shared/wsl-hook-relay-contract'
@@ -18,11 +23,30 @@ export async function installWslGuestHooks(options: {
guestHome: string
distro: string
installHooks: typeof installRemoteManagedAgentHooks
settings: ManagedHookDetectionSettings
warn: (message: string) => void
}): Promise<void> {
const { mux, guestHome, distro, installHooks, warn } = options
const { mux, guestHome, distro, installHooks, settings, warn } = options
let agents
try {
const detected = (await mux.request('preflight.detectAgents', {
commands: buildManagedHookDetectionCommands(settings, 'linux')
})) as { agents?: unknown }
agents = detectedManagedHookAgents(detected?.agents)
} catch (error) {
warn(
`[agent-hooks] WSL agent detection for '${distro}' failed: ${
error instanceof Error ? error.message : String(error)
}`
)
return
}
if (agents.length === 0) {
return
}
const results = await installHooks(createWslHookSftpAdapter(mux), guestHome, {
codexHomeDir: wslCodexRuntimeHomeForGuestHome(guestHome)
codexHomeDir: wslCodexRuntimeHomeForGuestHome(guestHome),
agents
})
const failed = results.filter((r) => r.state === 'error').length
if (failed > 0) {
@@ -5,6 +5,7 @@ import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { agentHookServer } from './server'
import type { ManagedHookDetectionSettings } from './managed-hook-detection-commands'
import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
import { getOpenCodePluginSource } from '../opencode/hook-service'
import type { PluginSources } from '../../relay/plugin-overlay'
@@ -59,6 +60,7 @@ export type WslHookRelayManagerDeps = {
waitForSentinel: typeof waitForWslRelaySentinel
ingest: (envelope: Record<string, unknown>, connectionId: string) => void
installHooks: typeof installRemoteManagedAgentHooks
managedHookSettings: () => ManagedHookDetectionSettings
/** Plugin source strings shipped to the guest relay so an Orca update needn't redeploy the relay bundle. */
pluginSources: () => PluginSources
warn: (message: string) => void
@@ -89,6 +91,7 @@ export const defaultWslHookRelayDeps: WslHookRelayManagerDeps = {
connectionId
),
installHooks: installRemoteManagedAgentHooks,
managedHookSettings: () => null,
// Why: only OpenCode is in scope for WSL now; the payload shape stays identical to SSH so Pi/OMP are additive later.
pluginSources: () => ({ opencodePluginSource: getOpenCodePluginSource() }),
warn: (message) => console.warn(message),
@@ -7,7 +7,6 @@
// exercises minus the wsl.exe byte transport (validated separately on-rig).
import { execFileSync, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createServer } from 'node:net'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
@@ -59,7 +58,7 @@ describe.skipIf(process.platform === 'win32')(
})
it('delivers a Claude hook POST from the live relay into ingestRemote and installs guest hooks', async () => {
fakeHome = mkdtempSync(join(tmpdir(), 'wsl-live-home-'))
fakeHome = mkdtempSync(join('/tmp', 'wsl-live-home-'))
const preferredPort = await pickFreePort()
const version = readFileSync(join(BUNDLE_DIR, '.version'), 'utf8').trim()
@@ -102,6 +101,12 @@ describe.skipIf(process.platform === 'win32')(
envelope as Parameters<AgentHookServer['ingestRemote']>[0],
connectionId
),
managedHookSettings: () => ({
agentCmdOverrides: {
claude: process.execPath,
codex: process.execPath
}
}),
warn: (message) => warns.push(message),
transientRetryDelayMs: 1
})
@@ -3,7 +3,6 @@
// the per-distro relay manager state machine with fault injection.
import { EventEmitter } from 'node:events'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -34,9 +33,13 @@ function createGuestHarness(): GuestHarness {
const clientDataCallbacks: ((data: Buffer) => void)[] = []
const closeCallbacks: (() => void)[] = []
const transport: MultiplexerTransport = {
write: (data) => {
setImmediate(() => relayFeed?.(data))
write: (data, onSettled) => {
setImmediate(() => {
relayFeed?.(data)
onSettled?.({ ok: true })
})
},
supportsWriteSettlement: true,
onData: (cb) => {
clientDataCallbacks.push(cb)
},
@@ -44,21 +47,24 @@ function createGuestHarness(): GuestHarness {
closeCallbacks.push(cb)
}
}
const guestDispatcher = new RelayDispatcher((data: Buffer) => {
setImmediate(() => {
for (const cb of clientDataCallbacks) {
cb(data)
}
})
})
const guestDispatcher = new RelayDispatcher(
(data: Buffer, onSettled) => {
setImmediate(() => {
for (const cb of clientDataCallbacks) {
cb(data)
}
onSettled({ ok: true })
})
},
{ supportsWriteCallback: true }
)
relayFeed = (data) => guestDispatcher.feed(data)
const mux = new SshChannelMultiplexer(transport)
return { transport, guestDispatcher, mux }
}
// Why skipIf: the fs bridge runs inside the Linux guest and is POSIX-only by
// design (posix.resolve). On a Windows dev host tmpdir() yields C:\ paths the
// bridge correctly refuses; Windows coverage comes from the live rig runs.
// Why skipIf: the fs bridge runs inside the Linux guest and is POSIX-only;
// Windows coverage comes from the live rig runs.
describe.skipIf(process.platform === 'win32')(
'createWslHookSftpAdapter over the guest fs bridge',
() => {
@@ -66,7 +72,7 @@ describe.skipIf(process.platform === 'win32')(
let harness: GuestHarness
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'wsl-guest-home-'))
home = mkdtempSync(join('/tmp', 'wsl-guest-home-'))
harness = createGuestHarness()
registerWslHookFsHandlers(harness.guestDispatcher, home)
})
@@ -166,13 +172,19 @@ describe('WslHookRelayManager', () => {
return child as unknown as ChildProcessWithoutNullStreams & { emitClose: () => void }
}
function guestTransport(registerInstallPlugins = true): MultiplexerTransport {
function guestTransport(
options: { registerInstallPlugins?: boolean; detectedAgents?: string[] } = {}
): MultiplexerTransport {
const { registerInstallPlugins = true, detectedAgents = ['codex'] } = options
const harness = createGuestHarness()
harnesses.push(harness)
registerWslHookFsHandlers(harness.guestDispatcher, home)
harness.guestDispatcher.onRequest(AGENT_HOOK_REQUEST_REPLAY_METHOD, async () => ({
replayed: 0
}))
harness.guestDispatcher.onRequest('preflight.detectAgents', async () => ({
agents: detectedAgents
}))
// A guest bundle predating the plugin overlay omits this handler (-32601).
if (registerInstallPlugins) {
harness.guestDispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async () => ({
@@ -212,6 +224,7 @@ describe('WslHookRelayManager', () => {
waitForSentinel: vi.fn(async () => guestTransport()),
ingest: vi.fn(),
installHooks: vi.fn(async () => []),
managedHookSettings: () => null,
pluginSources: () => ({ opencodePluginSource: '// opencode plugin source' }),
warn: vi.fn(),
transientRetryDelayMs: 1,
@@ -228,7 +241,8 @@ describe('WslHookRelayManager', () => {
expect(deps.spawnRelay).toHaveBeenCalledTimes(1)
// Codex is the one agent whose home Orca redirects for WSL sessions.
expect(deps.installHooks).toHaveBeenCalledWith(expect.anything(), home, {
codexHomeDir: `${home}/.local/share/orca/codex-runtime-home/home`
codexHomeDir: `${home}/.local/share/orca/codex-runtime-home/home`,
agents: ['codex']
})
expect(manager.getGuestEndpointFilePath('Ubuntu')).toBe(
@@ -261,7 +275,7 @@ describe('WslHookRelayManager', () => {
})
it('leaves the overlay dir null when the guest bundle lacks the installPlugins handler', async () => {
const waitForSentinel = vi.fn(async () => guestTransport(false))
const waitForSentinel = vi.fn(async () => guestTransport({ registerInstallPlugins: false }))
const { manager, deps } = createManager({ waitForSentinel })
manager.ensureForDistro('Ubuntu')
// Connect still completes (hooks install); the -32601 is swallowed silently.
@@ -272,6 +286,17 @@ describe('WslHookRelayManager', () => {
manager.disposeAll()
})
it('does not mutate managed agent homes when no WSL agents are detected', async () => {
const waitForSentinel = vi.fn(async () => guestTransport({ detectedAgents: [] }))
const { manager, deps } = createManager({ waitForSentinel })
manager.ensureForDistro('Ubuntu')
await vi.waitFor(() => expect(manager.getOpenCodeOverlayDir('Ubuntu')).toBe(opencodeOverlayDir))
expect(deps.installHooks).not.toHaveBeenCalled()
manager.disposeAll()
})
it('resolves the default distro for null and dedupes it with the explicit name', async () => {
const { manager, deps } = createManager({})
manager.ensureForDistro(null)
+10 -8
View File
@@ -18,6 +18,7 @@ import {
} from './wsl-hook-relay-deps'
import { wireWslRelayLink } from './wsl-hook-relay-link'
import { WslRelayRecovery } from './wsl-hook-relay-recovery'
import { wslHookRelayStateKey } from './wsl-hook-relay-state-key'
import { requestGuestOpenCodeOverlayDir } from './wsl-guest-plugin-install'
import { SshChannelMultiplexer, type MultiplexerTransport } from '../ssh/ssh-channel-multiplexer'
import { AGENT_HOOK_REQUEST_REPLAY_METHOD } from '../../shared/agent-hook-relay'
@@ -44,10 +45,6 @@ type DistroState = {
lastInstallAt?: number
}
function distroKey(distro: string): string {
return distro.trim().toLowerCase()
}
export class WslHookRelayManager {
private deps: WslHookRelayManagerDeps
private recovery: WslRelayRecovery
@@ -62,12 +59,12 @@ export class WslHookRelayManager {
isDistroRunning: (distro) => this.deps.isDistroRunning(distro),
warn: (message) => this.deps.warn(message),
isDisposed: () => this.disposed,
isCurrent: (state) => this.states.get(distroKey(state.distro)) === state,
isCurrent: (state) => this.states.get(wslHookRelayStateKey(state.distro)) === state,
restart: (distro) => this.ensureForDistro(distro),
dropState: (state) => {
// Why: identity-guarded — a fresh ensure() may own this key by now;
// deleting by key alone would orphan its live relay child.
const key = distroKey(state.distro)
const key = wslHookRelayStateKey(state.distro)
if (this.states.get(key) === state) {
this.states.delete(key)
}
@@ -75,6 +72,10 @@ export class WslHookRelayManager {
})
}
setManagedHookSettingsResolver(resolve: WslHookRelayManagerDeps['managedHookSettings']): void {
this.deps.managedHookSettings = resolve
}
/** Fire-and-forget from every WSL PTY spawn-env build; errors breadcrumb. */
ensureForDistro(distro: string | null): void {
if (this.disposed || this.deps.platform() !== 'win32' || !this.deps.remoteHooksEnabled()) {
@@ -89,7 +90,7 @@ export class WslHookRelayManager {
private stateFor(distro: string | null): DistroState | undefined {
// Empty key never matches a real (non-empty) distro state.
return this.states.get(distroKey(distro ?? this.defaultDistro ?? ''))
return this.states.get(wslHookRelayStateKey(distro ?? this.defaultDistro ?? ''))
}
/** Guest endpoint file path once known; null before first connect
@@ -120,7 +121,7 @@ export class WslHookRelayManager {
if (!distro || this.disposed) {
return
}
const key = distroKey(distro)
const key = wslHookRelayStateKey(distro)
const existing = this.states.get(key)
if (existing) {
if (existing.phase === 'running') {
@@ -280,6 +281,7 @@ export class WslHookRelayManager {
guestHome,
distro: state.distro,
installHooks: this.deps.installHooks,
settings: this.deps.managedHookSettings(),
warn: this.deps.warn
})
// Why: ship OpenCode's status plugin and record the guest overlay dir the
@@ -0,0 +1,3 @@
export function wslHookRelayStateKey(distro: string): string {
return distro.trim().toLowerCase()
}
+14 -3
View File
@@ -43,7 +43,7 @@ import { initObservability, shutdownObservability } from './observability'
import { registerMobileHandlers } from './ipc/mobile'
import { initTelemetry, shutdownTelemetry, trackAppOpenedOnce, track } from './telemetry/client'
import { classifyError } from './telemetry/classify-error'
import { runManagedHookInstallers } from './agent-hooks/install-telemetry'
import { recordManagedHookInstallFailure } from './agent-hooks/install-telemetry'
import {
indexPersistedPaneKeyPtyIds,
isLocalExecutionHost,
@@ -51,8 +51,8 @@ import {
sweepRestoredSubagentsWithoutLiveAgent
} from './agent-hooks/restored-subagent-liveness-sweep'
import {
applyAgentStatusHooksEnabled,
isAgentStatusHooksEnabled,
MANAGED_AGENT_HOOK_INSTALLERS,
removeManagedAgentHooks
} from './agent-hooks/managed-agent-hook-controls'
import { initCohortClassifier } from './telemetry/cohort-classifier'
@@ -1997,6 +1997,7 @@ void app.whenReady().then(async () => {
const activeOrcaProfile = ensureActiveOrcaProfile()
store = new Store({ dataFile: activeOrcaProfile.dataFile })
wslHookRelayManager.setManagedHookSettingsResolver(() => store?.getSettings() ?? null)
logStartupMilestone('store-loaded')
// Why: apply initial fallback WSL distro from store settings for global git/CLI calls.
setDefaultWslDistroOverride(store.getSettings().terminalWindowsWslDistro ?? null)
@@ -2557,7 +2558,17 @@ void app.whenReady().then(async () => {
if (shouldInstallManagedHooks(is.dev)) {
// Why: check the persisted off switch before any auto-install so removed hooks don't silently reappear on launch.
if (isAgentStatusHooksEnabled(store.getSettings())) {
runManagedHookInstallers(MANAGED_AGENT_HOOK_INSTALLERS)
const managedHookStore = store
void applyAgentStatusHooksEnabled(true, managedHookStore.getSettings(), {
shouldHydrateShellPath: app.isPackaged && process.platform !== 'win32',
onInstallError: recordManagedHookInstallFailure,
shouldContinue: (agent) => {
const settings = managedHookStore.getSettings()
return isAgentStatusHooksEnabled(settings) && !settings.disabledTuiAgents.includes(agent)
}
}).catch((error) => {
console.warn('[agent-hooks] failed to reconcile managed hooks on startup:', error)
})
} else {
removeManagedAgentHooks()
}
+54
View File
@@ -2,6 +2,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'
const {
applyAppIconMock,
applyAgentStatusHooksEnabledMock,
applyElectronProxySettingsMock,
browserWindowGetAllWindowsMock,
handleMock,
@@ -13,6 +14,7 @@ const {
rebuildAppMenuMock
} = vi.hoisted(() => ({
applyAppIconMock: vi.fn(),
applyAgentStatusHooksEnabledMock: vi.fn(),
applyElectronProxySettingsMock: vi.fn(),
browserWindowGetAllWindowsMock: vi.fn(),
handleMock: vi.fn(),
@@ -47,6 +49,10 @@ vi.mock('../app-icon', () => ({
applyAppIcon: applyAppIconMock
}))
vi.mock('../agent-hooks/managed-agent-hook-controls', () => ({
applyAgentStatusHooksEnabled: applyAgentStatusHooksEnabledMock
}))
vi.mock('../worktree-root-preparation', () => ({
prepareLocalWorktreeRootsForRepos: prepareLocalWorktreeRootsForReposMock
}))
@@ -81,6 +87,7 @@ describe('registerSettingsHandlers', () => {
handleMock.mockClear()
onMock.mockClear()
applyAppIconMock.mockClear()
applyAgentStatusHooksEnabledMock.mockReset().mockResolvedValue([])
applyElectronProxySettingsMock.mockClear()
applyElectronProxySettingsMock.mockResolvedValue({ source: 'settings' })
previewGhosttyImportMock.mockClear()
@@ -121,6 +128,53 @@ describe('registerSettingsHandlers', () => {
expect(event.returnValue).toEqual({ terminalMainSideEffectAuthority: false })
})
it('does not reconcile hooks when the disabled-agent set is unchanged', async () => {
const before = {
agentStatusHooksEnabled: true,
disabledTuiAgents: ['codex', 'claude']
}
store.getSettings.mockReturnValue(before)
store.updateSettings.mockReturnValue({
...before,
disabledTuiAgents: ['claude', 'codex']
})
registerSettingsHandlers(store as never)
const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as (
event: typeof settingsInvokeEvent,
args: { disabledTuiAgents: string[] }
) => Promise<unknown>
await handler(settingsInvokeEvent, { disabledTuiAgents: ['claude', 'codex'] })
expect(applyAgentStatusHooksEnabledMock).not.toHaveBeenCalled()
})
it('reconciles hooks when the disabled-agent set changes', async () => {
const before = {
agentStatusHooksEnabled: true,
disabledTuiAgents: ['codex', 'claude']
}
const updated = {
...before,
disabledTuiAgents: ['claude']
}
store.getSettings.mockReturnValue(before)
store.updateSettings.mockReturnValue(updated)
registerSettingsHandlers(store as never)
const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as (
event: typeof settingsInvokeEvent,
args: { disabledTuiAgents: string[] }
) => Promise<unknown>
await handler(settingsInvokeEvent, { disabledTuiAgents: ['claude'] })
expect(applyAgentStatusHooksEnabledMock).toHaveBeenCalledWith(
true,
updated,
expect.objectContaining({ shouldContinue: expect.any(Function) })
)
})
it('rejects durable Active Server writes through generic settings:set', async () => {
store.getSettings.mockReturnValue({ activeRuntimeEnvironmentId: null })
store.updateSettings.mockReturnValue({ activeRuntimeEnvironmentId: null })
+20 -6
View File
@@ -11,6 +11,7 @@ import { SETTINGS_CHANGED_WHITELIST, type SettingsChangedKey } from '../../share
import type { AgentAwakeService } from '../agent-awake-service'
import { sanitizeFloatingWorkspaceDirectorySetting } from './floating-workspace-directory'
import { applyAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
import { recordManagedHookInstallFailure } from '../agent-hooks/install-telemetry'
import { applyElectronProxySettings } from '../network/proxy-settings'
import { normalizeProxyBypassRules, normalizeProxyUrl } from '../../shared/network-proxy'
import { normalizeAppIconId } from '../../shared/app-icon'
@@ -23,6 +24,7 @@ import { prepareLocalWorktreeRootsForRepos } from '../worktree-root-preparation'
import { scheduleCurrentWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher'
import { applyPRBotAuthorOverride } from '../../shared/pr-bot-author-overrides'
import { resolveEnvironment } from '../../shared/runtime-environment-store'
import { haveSameDisabledTuiAgents } from '../../shared/tui-agent-selection'
// Why: the whitelist is the source-of-truth for which keys we emit on. Casting
// to a Set once at module load lets the IPC handler's per-key membership
@@ -148,14 +150,26 @@ export function registerSettingsHandlers(
if ('keepComputerAwakeWhileAgentsRun' in sanitizedArgs) {
agentAwakeService?.setEnabled(result.keepComputerAwakeWhileAgentsRun)
}
if (
'agentStatusHooksEnabled' in sanitizedArgs &&
before.agentStatusHooksEnabled !== result.agentStatusHooksEnabled
) {
const hookSettingChanged =
('agentStatusHooksEnabled' in sanitizedArgs &&
before.agentStatusHooksEnabled !== result.agentStatusHooksEnabled) ||
('disabledTuiAgents' in sanitizedArgs &&
!haveSameDisabledTuiAgents(before.disabledTuiAgents, result.disabledTuiAgents))
if (hookSettingChanged) {
try {
applyAgentStatusHooksEnabled(result.agentStatusHooksEnabled)
await applyAgentStatusHooksEnabled(result.agentStatusHooksEnabled, result, {
shouldHydrateShellPath: app.isPackaged && process.platform !== 'win32',
onInstallError: recordManagedHookInstallFailure,
shouldContinue: (agent) => {
const settings = store.getSettings()
return (
settings.agentStatusHooksEnabled !== false &&
!settings.disabledTuiAgents.includes(agent)
)
}
})
} catch (error) {
console.warn('[settings] failed to apply agentStatusHooksEnabled:', error)
console.warn('[settings] failed to reconcile managed agent hooks:', error)
}
}
if ('uiLanguage' in sanitizedArgs && before.uiLanguage !== result.uiLanguage) {
+82 -3
View File
@@ -182,7 +182,7 @@ const electronMocks = vi.hoisted(() => {
BrowserWindow: { fromId: vi.fn((_id: number): unknown => null) },
webContents: { fromId: vi.fn((_id: number): unknown => null) },
ipcMain,
app: { getPath: vi.fn(() => '/tmp') }
app: { getPath: vi.fn(() => '/tmp'), isPackaged: false }
}
})
@@ -255,6 +255,7 @@ const {
addGitHubIssueCommentMock,
listGitHubLabelsMock,
listGitHubAssignableUsersMock,
applyAgentStatusHooksEnabledMock,
detectInstalledAgentsWithShellPathHydrationMock,
detectRemoteAgentsMock,
markCodexProjectTrustedMock,
@@ -361,6 +362,7 @@ const {
addGitHubIssueCommentMock: vi.fn(),
listGitHubLabelsMock: vi.fn(),
listGitHubAssignableUsersMock: vi.fn(),
applyAgentStatusHooksEnabledMock: vi.fn(),
detectInstalledAgentsWithShellPathHydrationMock: vi.fn(),
detectRemoteAgentsMock: vi.fn(),
markCodexProjectTrustedMock: vi.fn(),
@@ -434,6 +436,10 @@ vi.mock('../ipc/preflight', () => ({
detectRemoteAgents: detectRemoteAgentsMock
}))
vi.mock('../agent-hooks/managed-agent-hook-controls', () => ({
applyAgentStatusHooksEnabled: applyAgentStatusHooksEnabledMock
}))
vi.mock('../agent-trust-presets', () => ({
markCodexProjectTrusted: markCodexProjectTrustedMock,
markCopilotFolderTrusted: markCopilotFolderTrustedMock,
@@ -617,6 +623,7 @@ vi.mock('../git/git-username', async () => {
function resetRuntimeTestMocks(): void {
resetPlatform()
electronMocks.app.isPackaged = false
clearConfiguredWorktreeSharedDirectoriesCacheForTests()
_resetTerminalViewAttributesForTest()
advertisedUrlWatcher.clear()
@@ -663,6 +670,7 @@ function resetRuntimeTestMocks(): void {
})
muxRequestMock.mockReset()
muxRequestMock.mockResolvedValue(undefined)
applyAgentStatusHooksEnabledMock.mockReset().mockResolvedValue([])
getActiveMultiplexerMock.mockReset()
getActiveMultiplexerMock.mockReturnValue({ request: muxRequestMock, notify: vi.fn() })
vi.mocked(createSetupRunnerScript).mockReset()
@@ -1665,7 +1673,7 @@ describe('OrcaRuntimeService', () => {
expect(updateSettings).not.toHaveBeenCalled()
})
it('accepts runtime-backed setting updates from paired clients', () => {
it('accepts runtime-backed setting updates from paired clients', async () => {
let settings = {
...store.getSettings(),
experimentalNewWorktreeCardStyle: false,
@@ -1684,7 +1692,7 @@ describe('OrcaRuntimeService', () => {
} as never)
expect(
runtime.updateClientSettings({
await runtime.updateClientSettings({
experimentalNewWorktreeCardStyle: true,
compactWorktreeCards: true,
minimaxGroupId: 'group-42',
@@ -1713,6 +1721,77 @@ describe('OrcaRuntimeService', () => {
})
})
it('reconciles hooks only when paired-client hook settings change', async () => {
electronMocks.app.isPackaged = true
let settings = {
...store.getSettings(),
agentStatusHooksEnabled: true,
disabledTuiAgents: ['codex', 'claude']
}
const updateSettings = vi.fn((updates: Partial<typeof settings>) => {
settings = { ...settings, ...updates }
return settings
})
const runtime = new OrcaRuntimeService({
...store,
getSettings: () => settings,
updateSettings
} as never)
await runtime.updateClientSettings({ disabledTuiAgents: ['claude', 'codex'] })
expect(applyAgentStatusHooksEnabledMock).not.toHaveBeenCalled()
await runtime.updateClientSettings({ disabledTuiAgents: ['claude'] })
expect(applyAgentStatusHooksEnabledMock).toHaveBeenCalledOnce()
expect(applyAgentStatusHooksEnabledMock).toHaveBeenCalledWith(
true,
expect.objectContaining({ disabledTuiAgents: ['claude'] }),
expect.objectContaining({
shouldContinue: expect.any(Function),
shouldHydrateShellPath: process.platform !== 'win32'
})
)
})
it('serializes paired-client hook reconciliation and reads current settings', async () => {
let settings = {
...store.getSettings(),
agentStatusHooksEnabled: true,
disabledTuiAgents: ['codex', 'claude']
}
const updateSettings = vi.fn((updates: Partial<typeof settings>) => {
settings = { ...settings, ...updates }
return settings
})
const firstReconciliation = deferred<[]>()
applyAgentStatusHooksEnabledMock
.mockImplementationOnce(() => firstReconciliation.promise)
.mockResolvedValueOnce([])
const runtime = new OrcaRuntimeService({
...store,
getSettings: () => settings,
updateSettings
} as never)
const first = runtime.updateClientSettings({ disabledTuiAgents: ['claude'] })
await vi.waitFor(() => expect(applyAgentStatusHooksEnabledMock).toHaveBeenCalledOnce())
const second = runtime.updateClientSettings({ disabledTuiAgents: [] })
expect(applyAgentStatusHooksEnabledMock).toHaveBeenCalledOnce()
const firstOptions = applyAgentStatusHooksEnabledMock.mock.calls[0]?.[2]
expect(firstOptions?.shouldContinue?.('claude')).toBe(true)
firstReconciliation.resolve([])
await Promise.all([first, second])
expect(applyAgentStatusHooksEnabledMock).toHaveBeenCalledTimes(2)
expect(applyAgentStatusHooksEnabledMock).toHaveBeenLastCalledWith(
true,
expect.objectContaining({ disabledTuiAgents: [] }),
expect.objectContaining({ shouldContinue: expect.any(Function) })
)
})
it('rejects relative paths for runtime nested repo scan/import', async () => {
const runtime = new OrcaRuntimeService({
...store,
+66 -26
View File
@@ -386,7 +386,11 @@ import {
isExpectedAgentProcess,
recognizeAgentProcess
} from '../../shared/agent-process-recognition'
import { isTuiAgentEnabled, pickTuiAgent } from '../../shared/tui-agent-selection'
import {
haveSameDisabledTuiAgents,
isTuiAgentEnabled,
pickTuiAgent
} from '../../shared/tui-agent-selection'
import {
resolveTuiAgentLaunchArgs,
resolveTuiAgentLaunchEnv
@@ -406,6 +410,7 @@ import {
} from '../agent-trust-presets'
import { markRemoteAgentWorkspaceTrusted } from '../remote-agent-trust-presets'
import { applyAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
import { recordManagedHookInstallFailure } from '../agent-hooks/install-telemetry'
import {
isWindowsAbsolutePathLike,
isPathInsideOrEqual,
@@ -533,7 +538,7 @@ import {
} from '../../shared/claude-agent-teams-tmux-compat'
import { joinWorktreeRelativePath } from './runtime-relative-paths'
import { collectMemorySnapshot } from '../memory/collector'
import { BrowserWindow, ipcMain, Notification } from 'electron'
import { app, BrowserWindow, ipcMain, Notification } from 'electron'
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
import type { BrowserBackend } from '../browser/browser-backend'
import { BrowserError } from '../browser/cdp-bridge'
@@ -2601,6 +2606,8 @@ export class OrcaRuntimeService {
private readonly runtimeId = randomUUID()
private readonly startedAt = Date.now()
private readonly store: RuntimeStore | null
private managedHookReconciliationGeneration = 0
private managedHookReconciliationTail: Promise<void> = Promise.resolve()
private readonly orchestrationEnvironmentTransport: OrchestrationEnvironmentTransport | null
private readonly orchestrationFederationTimers = new Map<string, ReturnType<typeof setInterval>>()
private readonly orchestrationFederationSyncs = new Map<string, Promise<void>>()
@@ -3346,7 +3353,34 @@ export class OrcaRuntimeService {
}
}
updateClientSettings(
private reconcileManagedAgentHooks(): Promise<void> {
const generation = ++this.managedHookReconciliationGeneration
const reconciliation = this.managedHookReconciliationTail.then(async () => {
if (generation !== this.managedHookReconciliationGeneration) {
return
}
const settings = this.store?.getSettings()
if (!settings) {
return
}
await applyAgentStatusHooksEnabled(settings.agentStatusHooksEnabled !== false, settings, {
shouldHydrateShellPath: app.isPackaged && process.platform !== 'win32',
onInstallError: recordManagedHookInstallFailure,
shouldContinue: (agent) => {
const current = this.store?.getSettings()
return (
current !== undefined &&
current.agentStatusHooksEnabled !== false &&
!current.disabledTuiAgents?.includes(agent)
)
}
})
})
this.managedHookReconciliationTail = reconciliation.catch(() => {})
return reconciliation
}
async updateClientSettings(
updates: Pick<
Partial<GlobalSettings>,
| 'agentStatusHooksEnabled'
@@ -3366,36 +3400,42 @@ export class OrcaRuntimeService {
| 'minimaxUsageModels'
| 'prBotAuthorOverrides'
>
): Pick<
GlobalSettings,
| 'defaultTuiAgent'
| 'disabledTuiAgents'
| 'agentCmdOverrides'
| 'agentDefaultArgs'
| 'agentDefaultEnv'
| 'agentStatusHooksEnabled'
| 'defaultTaskSource'
| 'defaultTaskViewPreset'
| 'visibleTaskProviders'
| 'defaultRepoSelection'
| 'defaultLinearTeamSelection'
| 'githubProjects'
| 'experimentalNewWorktreeCardStyle'
| 'compactWorktreeCards'
| 'minimaxGroupId'
| 'minimaxUsageModels'
| 'prBotAuthorOverrides'
): Promise<
Pick<
GlobalSettings,
| 'defaultTuiAgent'
| 'disabledTuiAgents'
| 'agentCmdOverrides'
| 'agentDefaultArgs'
| 'agentDefaultEnv'
| 'agentStatusHooksEnabled'
| 'defaultTaskSource'
| 'defaultTaskViewPreset'
| 'visibleTaskProviders'
| 'defaultRepoSelection'
| 'defaultLinearTeamSelection'
| 'githubProjects'
| 'experimentalNewWorktreeCardStyle'
| 'compactWorktreeCards'
| 'minimaxGroupId'
| 'minimaxUsageModels'
| 'prBotAuthorOverrides'
>
> {
if (!this.store?.getSettings || !this.store.updateSettings) {
throw new Error('runtime_unavailable')
}
const before = this.store.getSettings().agentStatusHooksEnabled !== false
const beforeSettings = this.store.getSettings()
const before = beforeSettings.agentStatusHooksEnabled !== false
this.store.updateSettings(updates, { notifyListeners: true })
const settings = this.store.getSettings()
if (
typeof updates.agentStatusHooksEnabled === 'boolean' &&
before !== updates.agentStatusHooksEnabled
(typeof updates.agentStatusHooksEnabled === 'boolean' &&
before !== updates.agentStatusHooksEnabled) ||
(updates.disabledTuiAgents !== undefined &&
!haveSameDisabledTuiAgents(beforeSettings.disabledTuiAgents, settings.disabledTuiAgents))
) {
applyAgentStatusHooksEnabled(updates.agentStatusHooksEnabled)
await this.reconcileManagedAgentHooks()
}
return this.getClientSettings()
}
+3 -1
View File
@@ -20,7 +20,9 @@ export const CLIENT_UI_METHODS: RpcMethod[] = [
defineMethod({
name: 'settings.update',
params: SettingsUpdate,
handler: (params, { runtime }) => ({ settings: runtime.updateClientSettings(params) })
handler: async (params, { runtime }) => ({
settings: await runtime.updateClientSettings(params)
})
}),
defineMethod({
name: 'settings.getTerminalQuickCommands',
@@ -0,0 +1,131 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD,
AGENT_HOOK_INSTALL_PLUGINS_METHOD
} from '../../shared/agent-hook-relay'
import { SshRelaySession } from './ssh-relay-session'
import type { SshConnection } from './ssh-connection'
import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures'
const { muxRequestMock, openConsumerSessionMock } = vi.hoisted(() => ({
muxRequestMock: vi.fn(),
openConsumerSessionMock: vi.fn()
}))
vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() }))
vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn().mockResolvedValue('') }))
vi.mock('./ssh-pty-consumer-session', () => ({
SSH_PTY_SOURCE_WINDOW_SU: 256 * 1024,
openSshPtyConsumerSession: openConsumerSessionMock
}))
vi.mock('./ssh-channel-multiplexer', () => ({
SshChannelMultiplexer: class MockSshChannelMultiplexer {
notify = vi.fn()
notifyWithSettlement = vi.fn()
request = muxRequestMock
onNotification = vi.fn().mockReturnValue(() => {})
onNotificationByMethod = vi.fn().mockReturnValue(() => {})
onRequest = vi.fn().mockReturnValue(() => {})
onDispose = vi.fn().mockReturnValue(() => {})
dispose = vi.fn()
isDisposed = vi.fn().mockReturnValue(false)
}
}))
vi.mock('../providers/ssh-pty-provider', () => ({
isSshPtyNotFoundError: vi.fn(() => false),
isSshPtyIdentityMismatchError: vi.fn(() => false),
SshPtyProvider: class MockSshPtyProvider {
onData = vi.fn().mockReturnValue(() => {})
onReplay = vi.fn().mockReturnValue(() => {})
onExit = vi.fn().mockReturnValue(() => {})
dispose = vi.fn()
}
}))
vi.mock('../providers/ssh-filesystem-provider', () => ({
SshFilesystemProvider: class MockSshFilesystemProvider {
dispose = vi.fn()
}
}))
vi.mock('../providers/ssh-git-provider', () => ({
SshGitProvider: class MockSshGitProvider {}
}))
vi.mock('../ipc/pty', () => ({
registerSshPtyProvider: vi.fn(),
unregisterSshPtyProvider: vi.fn(),
getSshPtyProvider: vi.fn(),
getPtyIdsForConnection: vi.fn().mockReturnValue([]),
clearPtyOwnershipForConnection: vi.fn(),
clearProviderPtyState: vi.fn(),
deletePtyOwnership: vi.fn(),
setPtyOwnership: vi.fn(),
restorePtyIncarnation: vi.fn(),
isCurrentPtyExit: vi.fn(() => true)
}))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
registerSshFilesystemProvider: vi.fn(),
unregisterSshFilesystemProvider: vi.fn(),
getSshFilesystemProvider: vi.fn()
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
registerSshGitProvider: vi.fn(),
unregisterSshGitProvider: vi.fn()
}))
const { registerSshPtyProvider } = await import('../ipc/pty')
describe('SshRelaySession managed hooks', () => {
beforeEach(() => {
vi.clearAllMocks()
process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1'
openConsumerSessionMock.mockImplementation(async (_mux, options) => ({
mode: 'legacy-fallback',
clientInstanceId: options.clientInstanceId,
serverBuildId: 'test-relay-build'
}))
mockDeploySuccess()
})
it('installs only detected hooks without blocking provider registration', async () => {
muxRequestMock.mockImplementation(async (method: string) => {
if (method === 'preflight.detectAgents') {
return { agents: ['codex'] }
}
return method === AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD
? { installers: 1, errors: 0 }
: { ok: true }
})
const { mockStore, mockPortForward, getMainWindow } = createMockDeps()
const sftp = vi.fn()
const connection = {
sftp,
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: ['codex']
})
)
const managedIndex = muxRequestMock.mock.calls.findIndex(
([method]) => method === AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD
)
const pluginsIndex = muxRequestMock.mock.calls.findIndex(
([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD
)
expect(muxRequestMock.mock.calls[pluginsIndex]?.[1]).toMatchObject({
piExtensionSource: expect.stringContaining('/hook/pi'),
ompExtensionSource: expect.stringContaining('/hook/omp')
})
expect(sftp).not.toHaveBeenCalled()
expect(muxRequestMock.mock.invocationCallOrder[pluginsIndex]).toBeLessThan(
vi.mocked(registerSshPtyProvider).mock.invocationCallOrder[0]
)
expect(vi.mocked(registerSshPtyProvider).mock.invocationCallOrder[0]).toBeLessThan(
muxRequestMock.mock.invocationCallOrder[managedIndex]
)
})
})
+18 -41
View File
@@ -207,50 +207,12 @@ describe('SshRelaySession', () => {
expect(registerSshGitProvider).toHaveBeenCalledWith('target-1', expect.anything())
})
it('installs all managed hooks in one relay RPC before plugins and PTY registration', async () => {
process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1'
muxRequestMock.mockResolvedValue({ installers: 14, errors: 0 })
const { mockStore, mockPortForward, getMainWindow } = createMockDeps()
const sftp = vi.fn()
const mockConn = {
sftp,
getHostKeyFingerprint: vi.fn(() => 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')
} as unknown as SshConnection
const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward)
await session.establish(mockConn)
const managedHookCalls = muxRequestMock.mock.calls.filter(
([method]) => method === AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD
)
const managedHookCallIndex = muxRequestMock.mock.calls.findIndex(
([method]) => method === AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD
)
const installPluginsCallIndex = muxRequestMock.mock.calls.findIndex(
([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD
)
expect(managedHookCalls).toEqual([
[
AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD,
{ hostKeyFingerprint: 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' }
]
])
expect(installPluginsCallIndex).toBeGreaterThanOrEqual(0)
const installPluginsParams = muxRequestMock.mock.calls[installPluginsCallIndex]?.[1]
expect(installPluginsParams).toMatchObject({
piExtensionSource: expect.stringContaining('/hook/pi'),
ompExtensionSource: expect.stringContaining('/hook/omp')
})
expect(sftp).not.toHaveBeenCalled()
expect(managedHookCallIndex).toBeLessThan(installPluginsCallIndex)
expect(muxRequestMock.mock.invocationCallOrder[installPluginsCallIndex]).toBeLessThan(
vi.mocked(registerSshPtyProvider).mock.invocationCallOrder[0]
)
})
it('continues provider registration when the relay managed-hook request fails', async () => {
process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1'
muxRequestMock.mockImplementation(async (method: string) => {
if (method === 'preflight.detectAgents') {
return { agents: ['codex'] }
}
if (method === AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD) {
throw new Error('runtime unavailable')
}
@@ -260,6 +222,12 @@ describe('SshRelaySession', () => {
const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward)
await session.establish({} as SshConnection)
await vi.waitFor(() =>
expect(muxRequestMock).toHaveBeenCalledWith(
AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD,
expect.anything()
)
)
expect(registerSshPtyProvider).toHaveBeenCalledWith('target-1', expect.anything())
expect(
@@ -270,6 +238,9 @@ describe('SshRelaySession', () => {
it('suppresses expected managed-hook teardown errors during disconnect', async () => {
process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1'
muxRequestMock.mockImplementation(async (method: string) => {
if (method === 'preflight.detectAgents') {
return { agents: ['codex'] }
}
if (method === AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD) {
throw Object.assign(new Error('request disposed'), { code: 'DISPOSED' })
}
@@ -281,6 +252,12 @@ describe('SshRelaySession', () => {
try {
await session.establish({} as SshConnection)
await vi.waitFor(() =>
expect(muxRequestMock).toHaveBeenCalledWith(
AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD,
expect.anything()
)
)
expect(registerSshPtyProvider).toHaveBeenCalledWith('target-1', expect.anything())
expect(warn.mock.calls.flat().join(' ')).not.toContain('relay managed hook install failed')
+26 -9
View File
@@ -18,6 +18,10 @@ import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider'
import { SshGitProvider } from '../providers/ssh-git-provider'
import { agentHookServer } from '../agent-hooks/server'
import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
import {
buildManagedHookDetectionCommands,
detectedManagedHookAgents
} from '../agent-hooks/managed-hook-detection-commands'
import {
AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD,
AGENT_HOOK_INSTALL_PLUGINS_METHOD,
@@ -697,11 +701,6 @@ export class SshRelaySession {
return false
}
await this.installManagedHooksOnRemote(mux)
if (shouldContinue && !shouldContinue()) {
return false
}
await this.installPluginsOnRelay(mux)
if (shouldContinue && !shouldContinue()) {
return false
@@ -833,6 +832,7 @@ export class SshRelaySession {
this.wireUpPtyEvents(ptyProvider, mux, providerGeneration)
this.wireUpAgentHookEvents(mux)
this.wireUpRemoteWorkspaceEvents(mux)
void this.installManagedHooksOnRemote(mux, shouldContinue)
return true
}
@@ -926,9 +926,15 @@ export class SshRelaySession {
})
}
// Why: hooks must exist before PTY spawn; relay-local work keeps all managed installs to one SSH round trip.
private async installManagedHooksOnRemote(mux: SshChannelMultiplexer): Promise<void> {
if (!isRemoteAgentHooksEnabled() || !this.areAgentStatusHooksEnabled()) {
private async installManagedHooksOnRemote(
mux: SshChannelMultiplexer,
shouldContinue?: () => boolean
): Promise<void> {
if (
!isRemoteAgentHooksEnabled() ||
!this.areAgentStatusHooksEnabled() ||
(shouldContinue && !shouldContinue())
) {
return
}
if (
@@ -940,8 +946,19 @@ 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)
if (agents.length === 0 || (shouldContinue && !shouldContinue())) {
return
}
const hostKeyFingerprint = this.requireReadyConnection().getHostKeyFingerprint?.()
const params = hostKeyFingerprint ? { hostKeyFingerprint } : {}
const params = {
...(hostKeyFingerprint ? { hostKeyFingerprint } : {}),
agents
}
const result = (await mux.request(AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, params)) as {
errors?: unknown
}
+40 -6
View File
@@ -27,11 +27,14 @@ describe('registerManagedHookInstaller', () => {
const installManagedHooks = vi.fn().mockResolvedValue({ installers: 14, errors: 0 })
const handler = captureHandler(() => ({ installManagedHooks }))
await expect(handler({}, context(controller.signal))).resolves.toEqual({
await expect(handler({ agents: ['codex'] }, context(controller.signal))).resolves.toEqual({
installers: 14,
errors: 0
})
expect(installManagedHooks).toHaveBeenCalledWith({ signal: controller.signal })
expect(installManagedHooks).toHaveBeenCalledWith({
signal: controller.signal,
agents: ['codex']
})
})
it('does not load or start the runtime for an already-cancelled request', async () => {
@@ -51,13 +54,44 @@ describe('registerManagedHookInstaller', () => {
const handler = captureHandler(() => ({ installManagedHooks }))
const fingerprint = 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
await handler({ hostKeyFingerprint: fingerprint }, context())
await handler({ hostKeyFingerprint: 'ssh://untrusted-host' }, context())
await handler({ hostKeyFingerprint: fingerprint, agents: ['codex'] }, context())
await handler({ hostKeyFingerprint: 'ssh://untrusted-host', agents: ['codex'] }, context())
expect(installManagedHooks).toHaveBeenNthCalledWith(1, {
signal: undefined,
hostKeyFingerprint: fingerprint
hostKeyFingerprint: fingerprint,
agents: ['codex']
})
expect(installManagedHooks).toHaveBeenNthCalledWith(2, { signal: undefined })
expect(installManagedHooks).toHaveBeenNthCalledWith(2, {
signal: undefined,
agents: ['codex']
})
})
it('fails closed when the detected agent allowlist is omitted', async () => {
const installManagedHooks = vi.fn().mockResolvedValue({ installers: 0, errors: 0 })
const handler = captureHandler(() => ({ installManagedHooks }))
await handler({}, context())
expect(installManagedHooks).toHaveBeenCalledWith({
signal: undefined,
agents: []
})
})
it('validates, deduplicates, and forwards the detected agent allowlist', async () => {
const installManagedHooks = vi.fn().mockResolvedValue({ installers: 1, errors: 0 })
const handler = captureHandler(() => ({ installManagedHooks }))
await handler({ agents: ['codex', 'codex'] }, context())
expect(installManagedHooks).toHaveBeenCalledWith({
signal: undefined,
agents: ['codex']
})
await expect(handler({ agents: ['unknown'] }, context())).rejects.toThrow(
'invalid_managed_hook_agents'
)
})
})
+17 -1
View File
@@ -4,6 +4,8 @@ import {
type AgentHookInstallManagedHooksParams
} from '../shared/agent-hook-relay'
import type { RelayDispatcher, RequestContext } from './dispatcher'
import type { AgentHookTarget } from '../shared/agent-hook-types'
import { isManagedAgentHookTarget } from '../shared/managed-agent-hook-targets'
export type ManagedHookInstallSummary = {
installers: number
@@ -14,6 +16,7 @@ export type ManagedHookRuntime = {
installManagedHooks: (options?: {
signal?: AbortSignal
hostKeyFingerprint?: string
agents?: readonly AgentHookTarget[]
}) => Promise<ManagedHookInstallSummary>
}
@@ -27,6 +30,17 @@ function readHostKeyFingerprint(params: unknown): string | undefined {
: undefined
}
function readAgents(params: unknown): AgentHookTarget[] {
const raw = (params as Partial<AgentHookInstallManagedHooksParams> | null)?.agents
if (raw === undefined) {
return []
}
if (!Array.isArray(raw) || !raw.every(isManagedAgentHookTarget)) {
throw new Error('invalid_managed_hook_agents')
}
return [...new Set(raw)]
}
let managedHookRuntime: ManagedHookRuntime | null = null
function loadManagedHookRuntime(): ManagedHookRuntime {
@@ -47,9 +61,11 @@ export function registerManagedHookInstaller(
async (params, context: RequestContext): Promise<ManagedHookInstallSummary> => {
context.signal?.throwIfAborted()
const hostKeyFingerprint = readHostKeyFingerprint(params)
const agents = readAgents(params)
return await loadRuntime().installManagedHooks({
signal: context.signal,
...(hostKeyFingerprint ? { hostKeyFingerprint } : {})
...(hostKeyFingerprint ? { hostKeyFingerprint } : {}),
agents
})
}
)
+2
View File
@@ -18,6 +18,7 @@ import { RelayAgentHookServer } from './agent-hook-server'
import { registerWslHookFsHandlers } from './wsl-hook-fs-bridge'
import { PluginOverlayManager } from './plugin-overlay'
import { createInstallPluginsHandler } from './wsl-install-plugins-handler'
import { PreflightHandler } from './preflight-handler'
import {
AGENT_HOOK_INSTALL_PLUGINS_METHOD,
AGENT_HOOK_NOTIFICATION_METHOD,
@@ -73,6 +74,7 @@ async function main(): Promise<void> {
envelope as unknown as Record<string, unknown>
)
})
new PreflightHandler(dispatcher)
dispatcher.onRequest(AGENT_HOOK_REQUEST_REPLAY_METHOD, async () => ({
replayed: hookServer.replayCachedPayloadsForPanes()
+3
View File
@@ -24,6 +24,7 @@
import type { ParsedAgentStatusPayload } from './agent-status-types'
import type { AgentProviderSessionMetadata } from './agent-session-resume'
import type { AgentHookTarget } from './agent-hook-types'
// Why: the local hook server knows the discriminator from URL pathname routing
// (`/hook/<source>`); the relay equally must tag each forwarded notification
@@ -115,6 +116,8 @@ export const AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD = 'agent_hook.installManage
export type AgentHookInstallManagedHooksParams = {
/** SHA-256 fingerprint of the server key negotiated by Orca's SSH transport. */
hostKeyFingerprint?: string
/** Positively detected and enabled agents allowed to mutate remote config. */
agents: readonly AgentHookTarget[]
}
/** Feature-flag env var. Read once at process start by Orca and the relay.
+8 -1
View File
@@ -21,7 +21,13 @@ export const AGENT_HOOK_TARGETS = [
] as const
export type AgentHookTarget = (typeof AGENT_HOOK_TARGETS)[number]
export type AgentHookInstallState = 'installed' | 'not_installed' | 'partial' | 'error'
export type AgentHookInstallState = 'installed' | 'not_installed' | 'partial' | 'error' | 'skipped'
export type AgentHookInstallSkipReason =
| 'agent_disabled'
| 'cli_not_found'
| 'cli_presence_unknown'
| 'hooks_disabled'
export type AgentHookInstallStatus = {
agent: AgentHookTarget
@@ -29,6 +35,7 @@ export type AgentHookInstallStatus = {
configPath: string
managedHooksPresent: boolean
detail: string | null
skipReason?: AgentHookInstallSkipReason
}
// Why: bumped whenever the managed script's request shape changes. The
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import {
extractExecutableToken,
hasPathSeparatorToken,
isSafeExecutableBasename,
isSafeOverrideExecutableToken
} from './managed-agent-command-token'
describe('managed agent command tokens', () => {
it('extracts quoted and escaped POSIX executable paths', () => {
expect(extractExecutableToken('"/opt/Agent Tools/codex" --flag', { platform: 'linux' })).toBe(
'/opt/Agent Tools/codex'
)
expect(extractExecutableToken('/opt/Agent\\ Tools/codex --flag', { platform: 'linux' })).toBe(
'/opt/Agent Tools/codex'
)
})
it('preserves Windows path separators', () => {
expect(
extractExecutableToken('"C:\\Program Files\\Claude\\claude.exe" --flag', {
platform: 'win32'
})
).toBe('C:\\Program Files\\Claude\\claude.exe')
})
it('distinguishes safe basenames from path tokens', () => {
expect(isSafeExecutableBasename('claude-code_1.2+')).toBe(true)
expect(isSafeExecutableBasename('../claude')).toBe(false)
expect(isSafeExecutableBasename('claude;echo')).toBe(false)
expect(hasPathSeparatorToken('C:\\Tools\\claude.exe')).toBe(true)
expect(hasPathSeparatorToken('/opt/codex')).toBe(true)
expect(hasPathSeparatorToken('codex')).toBe(false)
})
it('rejects traversal, control characters, and shell syntax in override paths', () => {
expect(isSafeOverrideExecutableToken('~/bin/codex')).toBe(true)
expect(isSafeOverrideExecutableToken('C:\\Program Files\\Claude\\claude.exe')).toBe(true)
expect(isSafeOverrideExecutableToken('../bin/codex')).toBe(false)
expect(isSafeOverrideExecutableToken('/opt/codex;echo')).toBe(false)
expect(isSafeOverrideExecutableToken('/opt/codex\0')).toBe(false)
})
})
+65
View File
@@ -0,0 +1,65 @@
const QUOTES = new Set(['"', "'"])
type ExtractExecutableTokenOptions = {
platform?: NodeJS.Platform
}
export function extractExecutableToken(
command: string | null | undefined,
options: ExtractExecutableTokenOptions = {}
): string | null {
const input = command?.trim()
if (!input) {
return null
}
const backslashEscapes = (options.platform ?? process.platform) !== 'win32'
let index = 0
let quote: string | null = null
let token = ''
while (index < input.length) {
const char = input[index]
if (quote) {
if (char === quote) {
quote = null
} else if (char === '\\' && quote === '"' && backslashEscapes && index + 1 < input.length) {
index += 1
token += input[index]
} else {
token += char
}
} else if (QUOTES.has(char)) {
quote = char
} else if (/\s/.test(char)) {
break
} else if (char === '\\' && backslashEscapes && index + 1 < input.length) {
index += 1
token += input[index]
} else {
token += char
}
index += 1
}
return token.length > 0 ? token : null
}
export function hasPathSeparatorToken(token: string): boolean {
return token.includes('/') || token.includes('\\')
}
export function isSafeExecutableBasename(token: string): boolean {
return /^[A-Za-z0-9._+-]+$/.test(token)
}
export function isSafeOverrideExecutableToken(token: string): boolean {
if (token.includes('\0')) {
return false
}
if (!hasPathSeparatorToken(token)) {
return isSafeExecutableBasename(token)
}
return (
!token.includes('..') &&
!/[|&;<>(){}[\]$`"'*!?]/.test(token) &&
/^[A-Za-z0-9._+\-/:\\~ ]+$/.test(token)
)
}
+32
View File
@@ -0,0 +1,32 @@
import { AGENT_HOOK_TARGETS, type AgentHookTarget } from './agent-hook-types'
import { getTuiAgentDetectCommands, TUI_AGENT_CONFIG } from './tui-agent-config'
import type { TuiAgent } from './types'
export type ManagedAgentHookTarget = {
agent: AgentHookTarget
tuiAgent: TuiAgent
executableCandidates: readonly string[]
}
function target(agent: AgentHookTarget): ManagedAgentHookTarget {
return {
agent,
tuiAgent: agent,
executableCandidates: getTuiAgentDetectCommands(TUI_AGENT_CONFIG[agent])
}
}
export const MANAGED_AGENT_HOOK_TARGETS: readonly ManagedAgentHookTarget[] =
AGENT_HOOK_TARGETS.map(target)
export function getManagedAgentHookTarget(
agent: AgentHookTarget
): ManagedAgentHookTarget | undefined {
return MANAGED_AGENT_HOOK_TARGETS.find((entry) => entry.agent === agent)
}
export function isManagedAgentHookTarget(value: unknown): value is AgentHookTarget {
return (
typeof value === 'string' && MANAGED_AGENT_HOOK_TARGETS.some((entry) => entry.agent === value)
)
}
+13 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
import { normalizeDisabledTuiAgents, pickTuiAgent } from './tui-agent-selection'
import {
haveSameDisabledTuiAgents,
normalizeDisabledTuiAgents,
pickTuiAgent
} from './tui-agent-selection'
describe('pickTuiAgent', () => {
it('uses an installed preferred agent', () => {
@@ -30,3 +34,11 @@ describe('normalizeDisabledTuiAgents', () => {
])
})
})
describe('haveSameDisabledTuiAgents', () => {
it('compares the normalized disabled-agent sets', () => {
expect(haveSameDisabledTuiAgents(['codex', 'claude'], ['claude', 'codex'])).toBe(true)
expect(haveSameDisabledTuiAgents(['codex', 'unknown'], ['codex'])).toBe(true)
expect(haveSameDisabledTuiAgents(['codex'], ['claude'])).toBe(false)
})
})
+6
View File
@@ -79,6 +79,12 @@ export function normalizeDisabledTuiAgents(value: unknown): TuiAgent[] {
return [...seen]
}
export function haveSameDisabledTuiAgents(left: unknown, right: unknown): boolean {
const leftSet = new Set(normalizeDisabledTuiAgents(left))
const rightSet = new Set(normalizeDisabledTuiAgents(right))
return leftSet.size === rightSet.size && [...leftSet].every((agent) => rightSet.has(agent))
}
export function isTuiAgentEnabled(agent: TuiAgent, disabled?: Iterable<unknown> | null): boolean {
return !normalizeDisabledTuiAgents(disabled).includes(agent)
}