mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
fix(agent-hooks): refresh existing Orca launchers when agent CLIs are unavailable (#13378)
* fix(agent-hooks): refresh existing shared hook scripts when the CLI is no longer detected A CLI that falls off PATH (moved npm prefix, relocated shim) keeps its user-wide config invoking Orca's launcher script under ~/.orca/agent-hooks, but the presence gate skips install() with no removal — freezing the script at whatever Orca generated last. Anyone in that state kept the pre-#11568 more.com-leaking .cmd forever, because no launcher script is ever deleted and Windows startup deliberately skips shell PATH hydration. Reconcile before gating: every existing shared launcher/statusline script is rewritten to the current template on each install pass. Creating scripts stays behind the presence gate — an existing file is proof of a prior install; a missing one means the gate did its job. Amp and Hermes are deliberately absent: they write provider-native plugin code with its own install lifecycle, not shared launchers. - refreshManagedScriptIfPresent() in installer-utils (no-op unless the file exists) - refreshManagedScripts() on the 11 launcher-writing services (openclaude via the shared Claude class) - reconcile pass in installManagedAgentHooks before presence detection, filtered by the agents option, best-effort per agent - coverage gate: a launcher written to ~/.orca/agent-hooks without a matching refresher entry fails the suite, in both directions * perf(agent-hooks): refresh launchers off the main thread * test(agent-hooks): keep refresh mode assertion POSIX-only
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
"../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/agent-hooks/managed-hook-script-refresh.ts",
|
||||
"../src/main/amp/hook-service.ts",
|
||||
"../src/main/antigravity/hook-service.ts",
|
||||
"../src/main/claude/hook-settings.ts",
|
||||
|
||||
@@ -7,7 +7,9 @@ const mocks = vi.hoisted(() => ({
|
||||
removeClaude: vi.fn(),
|
||||
removeCodex: vi.fn(),
|
||||
statusClaude: vi.fn(),
|
||||
statusCodex: vi.fn()
|
||||
statusCodex: vi.fn(),
|
||||
refreshClaude: vi.fn(),
|
||||
refreshCodex: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./local-agent-cli-presence', () => ({
|
||||
@@ -26,6 +28,10 @@ vi.mock('./managed-agent-hook-registry', () => ({
|
||||
MANAGED_AGENT_HOOK_STATUS_READERS: [
|
||||
['claude', mocks.statusClaude],
|
||||
['codex', mocks.statusCodex]
|
||||
],
|
||||
MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: [
|
||||
['claude', mocks.refreshClaude],
|
||||
['codex', mocks.refreshCodex]
|
||||
]
|
||||
}))
|
||||
|
||||
@@ -51,6 +57,8 @@ describe('managed agent hook controls', () => {
|
||||
mocks.installCodex.mockReturnValue(status('codex', 'installed'))
|
||||
mocks.removeClaude.mockReturnValue(status('claude', 'not_installed'))
|
||||
mocks.removeCodex.mockReturnValue(status('codex', 'not_installed'))
|
||||
mocks.refreshClaude.mockResolvedValue(undefined)
|
||||
mocks.refreshCodex.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('installs only agents with positively detected CLIs', async () => {
|
||||
@@ -73,6 +81,78 @@ describe('managed agent hook controls', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('refreshes existing scripts for agents whose CLI is no longer detected', async () => {
|
||||
mocks.detect.mockResolvedValue({
|
||||
claude: { state: 'missing' },
|
||||
codex: { state: 'found' }
|
||||
})
|
||||
|
||||
await installManagedAgentHooks({ agentCmdOverrides: {} })
|
||||
|
||||
// Why (#11549 aftermath): the skipped agent's user-wide config still invokes the
|
||||
// script, so a stale (leaking) script must not be frozen by the presence gate.
|
||||
expect(mocks.refreshClaude).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.refreshCodex).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.installClaude).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refreshes existing scripts even when CLI detection rejects', async () => {
|
||||
mocks.detect.mockRejectedValue(new Error('detection unavailable'))
|
||||
|
||||
await installManagedAgentHooks({ agentCmdOverrides: {} })
|
||||
|
||||
expect(mocks.refreshClaude).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.refreshCodex).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('awaits each script refresh before probing for CLIs', async () => {
|
||||
let releaseRefresh: (() => void) | undefined
|
||||
mocks.refreshClaude.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseRefresh = resolve
|
||||
})
|
||||
)
|
||||
mocks.detect.mockResolvedValue({
|
||||
claude: { state: 'missing' },
|
||||
codex: { state: 'missing' }
|
||||
})
|
||||
|
||||
const install = installManagedAgentHooks({ agentCmdOverrides: {} })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mocks.detect).not.toHaveBeenCalled()
|
||||
releaseRefresh?.()
|
||||
await install
|
||||
expect(mocks.detect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps installing when a script refresh throws', async () => {
|
||||
mocks.refreshClaude.mockRejectedValue(new Error('disk full'))
|
||||
mocks.detect.mockResolvedValue({
|
||||
claude: { state: 'found' },
|
||||
codex: { state: 'found' }
|
||||
})
|
||||
|
||||
const results = await installManagedAgentHooks({ agentCmdOverrides: {} })
|
||||
|
||||
expect(mocks.installClaude).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.installCodex).toHaveBeenCalledTimes(1)
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({ agent: 'claude', state: 'installed' }),
|
||||
expect.objectContaining({ agent: 'codex', state: 'installed' })
|
||||
])
|
||||
})
|
||||
|
||||
it('only refreshes scripts for the selected agents', async () => {
|
||||
mocks.detect.mockResolvedValue({ codex: { state: 'found' } })
|
||||
|
||||
await installManagedAgentHooks({ agentCmdOverrides: {} }, { agents: ['codex'] })
|
||||
|
||||
expect(mocks.refreshClaude).not.toHaveBeenCalled()
|
||||
expect(mocks.refreshCodex).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('fails closed when CLI detection rejects', async () => {
|
||||
mocks.detect.mockRejectedValue(new Error('detection unavailable'))
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { detectLocalManagedAgentCliPresence } from './local-agent-cli-presence'
|
||||
import {
|
||||
MANAGED_AGENT_HOOK_INSTALLERS,
|
||||
MANAGED_AGENT_HOOK_REMOVERS,
|
||||
MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS,
|
||||
MANAGED_AGENT_HOOK_STATUS_READERS,
|
||||
type ManagedAgentHookInstaller
|
||||
} from './managed-agent-hook-registry'
|
||||
@@ -87,10 +88,29 @@ function runInstaller(
|
||||
}
|
||||
}
|
||||
|
||||
// Why (#11549 aftermath): a CLI that falls off PATH keeps its user-wide config invoking
|
||||
// Orca's script, but the presence gate below then skips install() forever, freezing the
|
||||
// script at whatever Orca generated last. Existing scripts are Orca-owned, so bring them
|
||||
// current before any gating; creating new ones remains install()'s presence-gated job.
|
||||
async function refreshExistingManagedScripts(options: InstallOptions): Promise<void> {
|
||||
const allowed = options.agents ? new Set(options.agents) : null
|
||||
for (const [agent, refresh] of MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS) {
|
||||
if (allowed !== null && !allowed.has(agent)) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
console.error(`[agent-hooks] Failed to refresh ${agent} managed script:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function installManagedAgentHooks(
|
||||
settings: ManagedHookSettings = null,
|
||||
options: InstallOptions = {}
|
||||
): Promise<AgentHookInstallStatus[]> {
|
||||
await refreshExistingManagedScripts(options)
|
||||
const installers = selectedInstallers(options)
|
||||
const disabled = new Set(normalizeDisabledTuiAgents(settings?.disabledTuiAgents))
|
||||
const enabledInstallers = installers.filter(([agent]) => !disabled.has(agent))
|
||||
|
||||
@@ -16,6 +16,7 @@ import { kimiHookService } from '../kimi/hook-service'
|
||||
import { openClaudeHookService } from '../openclaude/hook-service'
|
||||
|
||||
export type ManagedAgentHookInstaller = readonly [HookInstallAgent, () => AgentHookInstallStatus]
|
||||
export type ManagedAgentHookScriptRefresher = readonly [HookInstallAgent, () => Promise<void>]
|
||||
export type ManagedAgentHookRemover = readonly [HookInstallAgent, () => AgentHookInstallStatus]
|
||||
export type ManagedAgentHookStatusReader = readonly [HookInstallAgent, () => AgentHookInstallStatus]
|
||||
|
||||
@@ -36,6 +37,27 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[]
|
||||
['kimi', () => kimiHookService.install()]
|
||||
]
|
||||
|
||||
// Why: covers the shared launcher/statusline scripts under ~/.orca/agent-hooks — the files a
|
||||
// user-wide agent config keeps invoking after the CLI falls off PATH. Amp and Hermes write
|
||||
// provider-native plugin code into their own config dirs with their own install lifecycles,
|
||||
// not shared launchers, so they are deliberately absent. Enforced by the coverage test in
|
||||
// managed-hook-script-refresh.test.ts: a new installer that writes a launcher without adding
|
||||
// a refresher here fails that test.
|
||||
export const MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: readonly ManagedAgentHookScriptRefresher[] = [
|
||||
['claude', () => claudeHookService.refreshManagedScripts()],
|
||||
['openclaude', () => openClaudeHookService.refreshManagedScripts()],
|
||||
['codex', () => codexHookService.refreshManagedScripts()],
|
||||
['gemini', () => geminiHookService.refreshManagedScripts()],
|
||||
['antigravity', () => antigravityHookService.refreshManagedScripts()],
|
||||
['cursor', () => cursorHookService.refreshManagedScripts()],
|
||||
['droid', () => droidHookService.refreshManagedScripts()],
|
||||
['command-code', () => commandCodeHookService.refreshManagedScripts()],
|
||||
['grok', () => grokHookService.refreshManagedScripts()],
|
||||
['copilot', () => copilotHookService.refreshManagedScripts()],
|
||||
['devin', () => devinHookService.refreshManagedScripts()],
|
||||
['kimi', () => kimiHookService.refreshManagedScripts()]
|
||||
]
|
||||
|
||||
export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [
|
||||
['claude', () => claudeHookService.remove()],
|
||||
['openclaude', () => openClaudeHookService.remove()],
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import type * as NodeFsModule from 'node:fs'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import type * as NodeFsPromisesModule from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type * as NodeOsModule from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
home: '',
|
||||
syncCalls: [] as { name: string; target: string }[],
|
||||
blockAsyncReads: false
|
||||
}))
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeFsModule>()
|
||||
const wrapped: Record<string, unknown> = { ...actual }
|
||||
for (const [name, value] of Object.entries(actual)) {
|
||||
if (!name.endsWith('Sync') || typeof value !== 'function') {
|
||||
continue
|
||||
}
|
||||
const original = value as ((...args: unknown[]) => unknown) & Record<string, unknown>
|
||||
const recorder = (...args: unknown[]): unknown => {
|
||||
state.syncCalls.push({ name, target: typeof args[0] === 'string' ? args[0] : '' })
|
||||
return original(...args)
|
||||
}
|
||||
Object.assign(recorder, original)
|
||||
wrapped[name] = recorder
|
||||
}
|
||||
return { ...wrapped, default: wrapped }
|
||||
})
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeFsPromisesModule>()
|
||||
return {
|
||||
...actual,
|
||||
default: actual,
|
||||
readFile: (...args: Parameters<typeof actual.readFile>) =>
|
||||
state.blockAsyncReads && String(args[0]).startsWith(state.home)
|
||||
? new Promise<never>(() => {})
|
||||
: actual.readFile(...args)
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeOsModule>()
|
||||
return { ...actual, default: actual, homedir: () => state.home }
|
||||
})
|
||||
|
||||
vi.mock('electron', () => ({ app: { getPath: () => '/tmp/orca-user-data' } }))
|
||||
|
||||
import { MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS } from './managed-agent-hook-registry'
|
||||
|
||||
function syncCallsUnderHome(): string[] {
|
||||
return state.syncCalls
|
||||
.filter((call) => call.target.startsWith(state.home))
|
||||
.map((call) => `${call.name}(${call.target})`)
|
||||
}
|
||||
|
||||
describe('managed hook script refresh stays off the main thread', () => {
|
||||
beforeEach(async () => {
|
||||
state.home = await mkdtemp(join(tmpdir(), 'orca-hook-refresh-main-thread-'))
|
||||
state.syncCalls = []
|
||||
state.blockAsyncReads = false
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
state.blockAsyncReads = false
|
||||
await rm(state.home, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('uses no synchronous HOME filesystem calls for missing or stale scripts', async () => {
|
||||
const hooksDir = join(state.home, '.orca', 'agent-hooks')
|
||||
const claudeScript = join(
|
||||
hooksDir,
|
||||
process.platform === 'win32' ? 'claude-hook.cmd' : 'claude-hook.sh'
|
||||
)
|
||||
await mkdir(hooksDir, { recursive: true })
|
||||
await writeFile(claudeScript, 'stale', 'utf-8')
|
||||
state.syncCalls = []
|
||||
|
||||
for (const [, refresh] of MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS) {
|
||||
await refresh()
|
||||
}
|
||||
|
||||
expect(syncCallsUnderHome()).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the event loop responsive while a HOME read is stalled', async () => {
|
||||
state.blockAsyncReads = true
|
||||
const refresh = MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS[0][1]()
|
||||
let settled = false
|
||||
void refresh.then(() => {
|
||||
settled = true
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 5))
|
||||
|
||||
expect(settled).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,206 @@
|
||||
// Why (#11549 aftermath): a CLI that falls off PATH keeps its user-wide config invoking
|
||||
// Orca's script while the presence gate skips install() forever. These tests pin the
|
||||
// repair — existing scripts come current, missing ones are never created.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
existsSync,
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
utimesSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type * as osModule from 'node:os'
|
||||
|
||||
let isolatedUserDataDir = ''
|
||||
let previousUserDataPath: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
|
||||
isolatedUserDataDir = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-user-data-'))
|
||||
process.env.ORCA_USER_DATA_PATH = isolatedUserDataDir
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (previousUserDataPath === undefined) {
|
||||
delete process.env.ORCA_USER_DATA_PATH
|
||||
} else {
|
||||
process.env.ORCA_USER_DATA_PATH = previousUserDataPath
|
||||
}
|
||||
rmSync(isolatedUserDataDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const { homedirMock } = vi.hoisted(() => ({
|
||||
homedirMock: vi.fn<() => string>()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => '/tmp/orca-user-data'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof osModule>()
|
||||
return {
|
||||
...actual,
|
||||
homedir: homedirMock.mockImplementation(actual.homedir)
|
||||
}
|
||||
})
|
||||
|
||||
import { refreshManagedScriptIfPresent } from './managed-hook-script-refresh'
|
||||
import {
|
||||
MANAGED_AGENT_HOOK_INSTALLERS,
|
||||
MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS
|
||||
} from './managed-agent-hook-registry'
|
||||
import { ClaudeHookService } from '../claude/hook-service'
|
||||
|
||||
async function withPlatform<T>(platform: NodeJS.Platform, run: () => T | Promise<T>): Promise<T> {
|
||||
const original = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
if (original) {
|
||||
Object.defineProperty(process, 'platform', original)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const STALE_WINDOWS_HOOK = [
|
||||
'@echo off',
|
||||
'setlocal',
|
||||
'if "%ORCA_AGENT_HOOK_PORT%"=="" goto :orca_agent_hook_drain_stdin',
|
||||
':orca_agent_hook_drain_stdin',
|
||||
'"%SystemRoot%\\System32\\more.com" >nul 2>nul',
|
||||
'exit /b 0',
|
||||
''
|
||||
].join('\r\n')
|
||||
|
||||
describe('refreshManagedScriptIfPresent', () => {
|
||||
it('rewrites an existing script and refuses to create a missing one', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-unit-'))
|
||||
try {
|
||||
const present = join(dir, 'present.cmd')
|
||||
writeFileSync(present, 'stale')
|
||||
expect(await refreshManagedScriptIfPresent(present, 'fresh')).toBe(true)
|
||||
expect(readFileSync(present, 'utf8')).toBe('fresh')
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
chmodSync(present, 0o600)
|
||||
}
|
||||
const fixedTime = new Date(1_000)
|
||||
utimesSync(present, fixedTime, fixedTime)
|
||||
expect(await refreshManagedScriptIfPresent(present, 'fresh')).toBe(true)
|
||||
expect(statSync(present).mtimeMs).toBe(fixedTime.getTime())
|
||||
if (process.platform !== 'win32') {
|
||||
expect(statSync(present).mode & 0o777).toBe(0o755)
|
||||
}
|
||||
|
||||
const missing = join(dir, 'missing.cmd')
|
||||
expect(await refreshManagedScriptIfPresent(missing, 'fresh')).toBe(false)
|
||||
expect(existsSync(missing)).toBe(false)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('managed hook script refresh', () => {
|
||||
it('brings a stale leaking script current without touching agent config', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-'))
|
||||
homedirMock.mockReturnValue(home)
|
||||
try {
|
||||
// Why: the bug population has a script (from a past install) but no reachable
|
||||
// CLI — and possibly no config dir Orca may create. Seed only the script.
|
||||
const hooksDir = join(home, '.orca', 'agent-hooks')
|
||||
mkdirSync(hooksDir, { recursive: true })
|
||||
writeFileSync(join(hooksDir, 'claude-hook.cmd'), STALE_WINDOWS_HOOK)
|
||||
|
||||
await withPlatform('win32', () => new ClaudeHookService().refreshManagedScripts())
|
||||
|
||||
const refreshed = readFileSync(join(hooksDir, 'claude-hook.cmd'), 'utf8')
|
||||
expect(refreshed).toContain('if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0')
|
||||
expect(refreshed).not.toContain('if "%ORCA_AGENT_HOOK_PORT%"=="" goto')
|
||||
// Why: refresh must not resurrect config for a CLI the user may have removed.
|
||||
expect(existsSync(join(home, '.claude'))).toBe(false)
|
||||
// Why: the statusline script was never installed here, so it must not appear.
|
||||
expect(existsSync(join(hooksDir, 'claude-statusline.cmd'))).toBe(false)
|
||||
} finally {
|
||||
homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir())
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('covers every shared launcher script with a refresher', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-coverage-'))
|
||||
homedirMock.mockReturnValue(home)
|
||||
const previousGrokHome = process.env.GROK_HOME
|
||||
const previousKimiHome = process.env.KIMI_CODE_HOME
|
||||
delete process.env.GROK_HOME
|
||||
delete process.env.KIMI_CODE_HOME
|
||||
try {
|
||||
await withPlatform('win32', () => {
|
||||
for (const [, install] of MANAGED_AGENT_HOOK_INSTALLERS) {
|
||||
install()
|
||||
}
|
||||
})
|
||||
const hooksDir = join(home, '.orca', 'agent-hooks')
|
||||
const files = readdirSync(hooksDir)
|
||||
expect(files.length).toBeGreaterThan(0)
|
||||
const refresherAgents = MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS.map(([agent]) => agent)
|
||||
// Why: an installer that writes a shared launcher but skips the refresher list would
|
||||
// recreate the frozen-stale-script class this suite exists to prevent.
|
||||
for (const file of files) {
|
||||
expect(
|
||||
refresherAgents.some((agent) => file.startsWith(`${agent}-`)),
|
||||
`${file} is written to ~/.orca/agent-hooks but no refresher owns it`
|
||||
).toBe(true)
|
||||
}
|
||||
// Why: the reverse direction — a refresher naming an agent that writes nothing is a
|
||||
// stale registry entry, likely a renamed script file.
|
||||
for (const agent of refresherAgents) {
|
||||
expect(
|
||||
files.some((file) => file.startsWith(`${agent}-`)),
|
||||
`refresher for ${agent} matches no installed script`
|
||||
).toBe(true)
|
||||
}
|
||||
} finally {
|
||||
homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir())
|
||||
if (previousGrokHome === undefined) {
|
||||
delete process.env.GROK_HOME
|
||||
} else {
|
||||
process.env.GROK_HOME = previousGrokHome
|
||||
}
|
||||
if (previousKimiHome === undefined) {
|
||||
delete process.env.KIMI_CODE_HOME
|
||||
} else {
|
||||
process.env.KIMI_CODE_HOME = previousKimiHome
|
||||
}
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('creates nothing anywhere when no managed scripts exist', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-empty-'))
|
||||
homedirMock.mockReturnValue(home)
|
||||
try {
|
||||
await withPlatform('win32', async () => {
|
||||
for (const [agent, refresh] of MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS) {
|
||||
await expect(refresh(), `${agent} refresh on empty home`).resolves.toBeUndefined()
|
||||
}
|
||||
})
|
||||
// Why: a refresh pass on a machine with no prior installs must be a strict no-op.
|
||||
expect(readdirSync(home)).toEqual([])
|
||||
} finally {
|
||||
homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir())
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { chmod, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { grantDirAclAsync, isPermissionError } from '../win32-utils'
|
||||
|
||||
type ExistingScript = { exists: false } | { exists: true; content: string | null }
|
||||
|
||||
function isMissingPathError(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === 'ENOENT'
|
||||
}
|
||||
|
||||
async function readExistingScript(scriptPath: string): Promise<ExistingScript> {
|
||||
try {
|
||||
return { exists: true, content: await readFile(scriptPath, 'utf-8') }
|
||||
} catch (error) {
|
||||
if (isMissingPathError(error)) {
|
||||
return { exists: false }
|
||||
}
|
||||
try {
|
||||
await stat(scriptPath)
|
||||
return { exists: true, content: null }
|
||||
} catch (statError) {
|
||||
if (isMissingPathError(statError)) {
|
||||
return { exists: false }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function scriptStillExists(scriptPath: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(scriptPath)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (isMissingPathError(error)) {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function writeScriptWithAclRetry(scriptPath: string, content: string): Promise<void> {
|
||||
try {
|
||||
await writeFile(scriptPath, content, 'utf-8')
|
||||
} catch (error) {
|
||||
if (isPermissionError(error) && process.platform === 'win32') {
|
||||
try {
|
||||
await grantDirAclAsync(dirname(scriptPath))
|
||||
await writeFile(scriptPath, content, 'utf-8')
|
||||
return
|
||||
} catch {
|
||||
// Re-throw the original permission error.
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Why: refresh must not block Electron's main thread or create state for an absent CLI.
|
||||
export async function refreshManagedScriptIfPresent(
|
||||
scriptPath: string,
|
||||
content: string
|
||||
): Promise<boolean> {
|
||||
const existing = await readExistingScript(scriptPath)
|
||||
if (!existing.exists) {
|
||||
return false
|
||||
}
|
||||
if (existing.content === content) {
|
||||
if (process.platform !== 'win32') {
|
||||
await chmod(scriptPath, 0o755)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const tmpPath = join(dirname(scriptPath), `.${Date.now()}-${randomUUID()}.tmp`)
|
||||
try {
|
||||
await writeScriptWithAclRetry(tmpPath, content)
|
||||
if (process.platform !== 'win32') {
|
||||
await chmod(tmpPath, 0o755)
|
||||
}
|
||||
if (!(await scriptStillExists(scriptPath))) {
|
||||
return false
|
||||
}
|
||||
await rename(tmpPath, scriptPath)
|
||||
return true
|
||||
} finally {
|
||||
await rm(tmpPath, { force: true }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
writeHooksJsonRemote,
|
||||
writeManagedScriptRemote
|
||||
} from '../agent-hooks/installer-utils-remote'
|
||||
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
|
||||
import {
|
||||
buildPosixHookPayloadCapture,
|
||||
buildWindowsHookEnvironmentGuardLines,
|
||||
@@ -296,6 +297,18 @@ function removeInstalledConfig(config: HooksConfig): void {
|
||||
}
|
||||
|
||||
export class AntigravityHookService {
|
||||
async refreshManagedScripts(): Promise<void> {
|
||||
await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript())
|
||||
if (process.platform === 'win32') {
|
||||
for (const event of ANTIGRAVITY_EVENTS) {
|
||||
await refreshManagedScriptIfPresent(
|
||||
getWindowsWrapperScriptPath(event),
|
||||
getWindowsWrapperScript(event.eventName)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getStatus(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
writeHooksJsonRemote,
|
||||
writeManagedScriptRemote
|
||||
} from '../agent-hooks/installer-utils-remote'
|
||||
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
|
||||
import {
|
||||
buildPosixHookPayloadCapture,
|
||||
buildWindowsHookEnvironmentGuardLines,
|
||||
@@ -171,6 +172,18 @@ export class ClaudeHookService {
|
||||
return { agent: this.options.agent, state, configPath, managedHooksPresent, detail }
|
||||
}
|
||||
|
||||
async refreshManagedScripts(): Promise<void> {
|
||||
await refreshManagedScriptIfPresent(
|
||||
getManagedScriptPath(this.options.settings),
|
||||
getManagedScript('local', { skipWhenDevinImportsClaude: this.options.agent === 'claude' })
|
||||
)
|
||||
// Why: no agent gate — the statusline script only ever exists for claude, so presence is the gate.
|
||||
await refreshManagedScriptIfPresent(
|
||||
getStatusLineScriptPath(this.options.settings),
|
||||
getManagedStatusLineScript('local')
|
||||
)
|
||||
}
|
||||
|
||||
install(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath(this.options.settings)
|
||||
const scriptPath = getManagedScriptPath(this.options.settings)
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
writeManagedScript,
|
||||
type HookDefinition
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
|
||||
import { resolveHooksJsonWritePath } from '../agent-hooks/hook-config-write-path'
|
||||
import { writeFileAtomically } from '../codex-accounts/fs-utils'
|
||||
import {
|
||||
@@ -1021,6 +1022,10 @@ function getWslReconciliationKey(runtimeHomePath: string): string {
|
||||
}
|
||||
|
||||
export class CodexHookService {
|
||||
async refreshManagedScripts(): Promise<void> {
|
||||
await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript())
|
||||
}
|
||||
|
||||
private readonly wslReconciliationGeneration = new Map<string, number>()
|
||||
|
||||
private supersedeWslReconciliation(runtimeHomePath: string | null | undefined): number {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
writeManagedScript,
|
||||
type HookDefinition
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
|
||||
import {
|
||||
readHooksJsonRemote,
|
||||
writeHooksJsonRemote,
|
||||
@@ -88,6 +89,10 @@ function buildInstalledConfig(
|
||||
}
|
||||
|
||||
export class CommandCodeHookService {
|
||||
async refreshManagedScripts(): Promise<void> {
|
||||
await refreshManagedScriptIfPresent(getManagedScriptPath(), buildCommandCodeManagedScript())
|
||||
}
|
||||
|
||||
getStatus(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
writeManagedScript,
|
||||
type HookDefinition
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
|
||||
import {
|
||||
readHooksJsonRemote,
|
||||
writeHooksJsonRemote,
|
||||
@@ -183,6 +184,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
}
|
||||
|
||||
export class CopilotHookService {
|
||||
async refreshManagedScripts(): Promise<void> {
|
||||
await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript())
|
||||
}
|
||||
|
||||
getStatus(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
writeManagedScript,
|
||||
type HookDefinition
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
|
||||
import {
|
||||
readHooksJsonRemote,
|
||||
writeHooksJsonRemote,
|
||||
@@ -101,6 +102,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
}
|
||||
|
||||
export class CursorHookService {
|
||||
async refreshManagedScripts(): Promise<void> {
|
||||
await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript())
|
||||
}
|
||||
|
||||
getStatus(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
writeHooksJson,
|
||||
writeManagedScript
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
|
||||
import {
|
||||
readTextFileRemote,
|
||||
writeHooksJsonRemote,
|
||||
@@ -79,6 +80,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
}
|
||||
|
||||
export class DevinHookService {
|
||||
async refreshManagedScripts(): Promise<void> {
|
||||
await refreshManagedScriptIfPresent(getDevinManagedScriptPath(), getManagedScript())
|
||||
}
|
||||
|
||||
getStatus(): AgentHookInstallStatus {
|
||||
const configPath = getDevinConfigPath()
|
||||
const scriptPath = getDevinManagedScriptPath()
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type HookDefinition,
|
||||
type HooksConfig
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
|
||||
import {
|
||||
readHooksJsonRemote,
|
||||
writeHooksJsonRemote,
|
||||
@@ -159,6 +160,10 @@ function buildInstalledDroidConfig(
|
||||
}
|
||||
|
||||
export class DroidHookService {
|
||||
async refreshManagedScripts(): Promise<void> {
|
||||
await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript())
|
||||
}
|
||||
|
||||
getStatus(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
writeManagedScript,
|
||||
type HookDefinition
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
|
||||
import {
|
||||
readHooksJsonRemote,
|
||||
writeHooksJsonRemote,
|
||||
@@ -97,6 +98,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
}
|
||||
|
||||
export class GeminiHookService {
|
||||
async refreshManagedScripts(): Promise<void> {
|
||||
await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript())
|
||||
}
|
||||
|
||||
getStatus(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
writeManagedScript,
|
||||
type HookDefinition
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
|
||||
import {
|
||||
readHooksJsonRemote,
|
||||
writeHooksJsonRemote,
|
||||
@@ -182,6 +183,10 @@ function buildInstalledConfig(
|
||||
}
|
||||
|
||||
export class GrokHookService {
|
||||
async refreshManagedScripts(): Promise<void> {
|
||||
await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript())
|
||||
}
|
||||
|
||||
getStatus(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
wrapPosixHookCommand,
|
||||
writeManagedScript
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
|
||||
import {
|
||||
readTextFileRemote,
|
||||
writeManagedScriptRemote,
|
||||
@@ -155,6 +156,10 @@ function buildStatus(present: Set<string>, configPath: string): AgentHookInstall
|
||||
}
|
||||
|
||||
export class KimiHookService {
|
||||
async refreshManagedScripts(): Promise<void> {
|
||||
await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript())
|
||||
}
|
||||
|
||||
getStatus(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const text = readConfigToml(configPath)
|
||||
|
||||
Reference in New Issue
Block a user