Files
orca/src/main/agent-hooks/managed-hook-script-refresh.ts
T
Brennan Benson 2ee43bfc0d 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
2026-08-10 16:34:15 -07:00

91 lines
2.5 KiB
TypeScript

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)
}
}