From 2ee43bfc0d8af45fe5c6fa4f368bd95285acd76d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:34:15 -0700 Subject: [PATCH] fix(agent-hooks): refresh existing Orca launchers when agent CLIs are unavailable (#13378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- config/tsconfig.cli.json | 1 + .../managed-agent-hook-controls.test.ts | 82 ++++++- .../managed-agent-hook-controls.ts | 20 ++ .../managed-agent-hook-registry.ts | 22 ++ ...ed-hook-script-refresh-main-thread.test.ts | 101 +++++++++ .../managed-hook-script-refresh.test.ts | 206 ++++++++++++++++++ .../managed-hook-script-refresh.ts | 90 ++++++++ src/main/antigravity/hook-service.ts | 13 ++ src/main/claude/hook-service.ts | 13 ++ src/main/codex/hook-service.ts | 5 + src/main/command-code/hook-service.ts | 5 + src/main/copilot/hook-service.ts | 5 + src/main/cursor/hook-service.ts | 5 + src/main/devin/hook-service.ts | 5 + src/main/droid/hook-service.ts | 5 + src/main/gemini/hook-service.ts | 5 + src/main/grok/hook-service.ts | 5 + src/main/kimi/hook-service.ts | 5 + 18 files changed, 592 insertions(+), 1 deletion(-) create mode 100644 src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts create mode 100644 src/main/agent-hooks/managed-hook-script-refresh.test.ts create mode 100644 src/main/agent-hooks/managed-hook-script-refresh.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 14295cb475a..747780e416b 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -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", diff --git a/src/main/agent-hooks/managed-agent-hook-controls.test.ts b/src/main/agent-hooks/managed-agent-hook-controls.test.ts index f87fd8a06dd..60004f94df0 100644 --- a/src/main/agent-hooks/managed-agent-hook-controls.test.ts +++ b/src/main/agent-hooks/managed-agent-hook-controls.test.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((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')) diff --git a/src/main/agent-hooks/managed-agent-hook-controls.ts b/src/main/agent-hooks/managed-agent-hook-controls.ts index 0e767ee72b0..0de10c6417c 100644 --- a/src/main/agent-hooks/managed-agent-hook-controls.ts +++ b/src/main/agent-hooks/managed-agent-hook-controls.ts @@ -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 { + 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 { + await refreshExistingManagedScripts(options) const installers = selectedInstallers(options) const disabled = new Set(normalizeDisabledTuiAgents(settings?.disabledTuiAgents)) const enabledInstallers = installers.filter(([agent]) => !disabled.has(agent)) diff --git a/src/main/agent-hooks/managed-agent-hook-registry.ts b/src/main/agent-hooks/managed-agent-hook-registry.ts index 1c17da381ce..4772a763b6c 100644 --- a/src/main/agent-hooks/managed-agent-hook-registry.ts +++ b/src/main/agent-hooks/managed-agent-hook-registry.ts @@ -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] 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()], diff --git a/src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts b/src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts new file mode 100644 index 00000000000..e80011d803f --- /dev/null +++ b/src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts @@ -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() + const wrapped: Record = { ...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 + 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() + return { + ...actual, + default: actual, + readFile: (...args: Parameters) => + state.blockAsyncReads && String(args[0]).startsWith(state.home) + ? new Promise(() => {}) + : actual.readFile(...args) + } +}) + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal() + 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((resolve) => setTimeout(resolve, 5)) + + expect(settled).toBe(false) + }) +}) diff --git a/src/main/agent-hooks/managed-hook-script-refresh.test.ts b/src/main/agent-hooks/managed-hook-script-refresh.test.ts new file mode 100644 index 00000000000..799f8acf58c --- /dev/null +++ b/src/main/agent-hooks/managed-hook-script-refresh.test.ts @@ -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() + 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(platform: NodeJS.Platform, run: () => T | Promise): Promise { + 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 }) + } + }) +}) diff --git a/src/main/agent-hooks/managed-hook-script-refresh.ts b/src/main/agent-hooks/managed-hook-script-refresh.ts new file mode 100644 index 00000000000..5842a9dfbfe --- /dev/null +++ b/src/main/agent-hooks/managed-hook-script-refresh.ts @@ -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 { + 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 { + try { + await stat(scriptPath) + return true + } catch (error) { + if (isMissingPathError(error)) { + return false + } + throw error + } +} + +async function writeScriptWithAclRetry(scriptPath: string, content: string): Promise { + 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 { + 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) + } +} diff --git a/src/main/antigravity/hook-service.ts b/src/main/antigravity/hook-service.ts index e83160a3549..535eb3a3682 100644 --- a/src/main/antigravity/hook-service.ts +++ b/src/main/antigravity/hook-service.ts @@ -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 { + 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() diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index ba2dfac1351..eb8891a5bf7 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -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 { + 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) diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 1c4052cefca..3bf087b14d9 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -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 { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + private readonly wslReconciliationGeneration = new Map() private supersedeWslReconciliation(runtimeHomePath: string | null | undefined): number { diff --git a/src/main/command-code/hook-service.ts b/src/main/command-code/hook-service.ts index 2906ffd2da3..1e1ab9e56af 100644 --- a/src/main/command-code/hook-service.ts +++ b/src/main/command-code/hook-service.ts @@ -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 { + await refreshManagedScriptIfPresent(getManagedScriptPath(), buildCommandCodeManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() diff --git a/src/main/copilot/hook-service.ts b/src/main/copilot/hook-service.ts index 6a8206d0b59..332731fe352 100644 --- a/src/main/copilot/hook-service.ts +++ b/src/main/copilot/hook-service.ts @@ -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 { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() diff --git a/src/main/cursor/hook-service.ts b/src/main/cursor/hook-service.ts index 7b5f3b90dda..63ff9b36a71 100644 --- a/src/main/cursor/hook-service.ts +++ b/src/main/cursor/hook-service.ts @@ -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 { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() diff --git a/src/main/devin/hook-service.ts b/src/main/devin/hook-service.ts index 82b00a71c3c..6cff0d2b824 100644 --- a/src/main/devin/hook-service.ts +++ b/src/main/devin/hook-service.ts @@ -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 { + await refreshManagedScriptIfPresent(getDevinManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getDevinConfigPath() const scriptPath = getDevinManagedScriptPath() diff --git a/src/main/droid/hook-service.ts b/src/main/droid/hook-service.ts index 508f24e98c8..fdef56a2401 100644 --- a/src/main/droid/hook-service.ts +++ b/src/main/droid/hook-service.ts @@ -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 { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() diff --git a/src/main/gemini/hook-service.ts b/src/main/gemini/hook-service.ts index 1615ea336f9..4e44dba7bb6 100644 --- a/src/main/gemini/hook-service.ts +++ b/src/main/gemini/hook-service.ts @@ -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 { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() diff --git a/src/main/grok/hook-service.ts b/src/main/grok/hook-service.ts index 51a6150eab6..1b6e8bba165 100644 --- a/src/main/grok/hook-service.ts +++ b/src/main/grok/hook-service.ts @@ -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 { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() diff --git a/src/main/kimi/hook-service.ts b/src/main/kimi/hook-service.ts index 94f5d11ea3e..8036330272a 100644 --- a/src/main/kimi/hook-service.ts +++ b/src/main/kimi/hook-service.ts @@ -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, configPath: string): AgentHookInstall } export class KimiHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const text = readConfigToml(configPath)