Files
orca/src/main/hooks.ts
T
Neil 8d3e32a2ff Fix setup-provisioned skills missing at agent startup (#17124)
* fix(setup): let repos gate agent startup

* test(setup): update runner call expectations
2026-08-29 01:53:31 -07:00

222 lines
7.5 KiB
TypeScript

import { readFileSync, existsSync } from 'node:fs'
import { join } from 'node:path'
import { exec } from 'node:child_process'
import { parseOrcaYaml } from '../shared/orca-yaml'
import { resolveHookCommandSourcePolicy } from '../shared/hook-command-source-policy'
import { getEffectiveHooksFromConfig } from './effective-hook-config'
import { getHookRuntimeTarget, getHookWslContext } from './hook-runtime-target'
import { getSetupEnvVars } from './setup-hook-env-vars'
import { iterateLfScriptLines } from './setup-runner-script-text'
import { promptGuardShellEnv } from './git/runner'
import { dropIncoherentCondaActivationEnv } from './pty/conda-activation-env'
import { toLinuxPath } from './wsl'
import { runWslProcess } from './wsl/wsl-runner'
import type { HookRuntimeTarget } from './hook-runtime-target'
import type { OrcaHooks } from '../shared/orca-yaml-hook-types'
import type { Repo } from '../shared/repo-types'
import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime'
const HOOK_TIMEOUT = 120_000 // 2 minutes
function getHookShell(): string | undefined {
if (process.platform === 'win32') {
return process.env.ComSpec || 'cmd.exe'
}
return '/bin/bash'
}
export { parseOrcaYaml }
/**
* Load hooks from orca.yaml in the given repo root.
*/
export function loadHooks(repoPath: string): OrcaHooks | null {
const yamlPath = join(repoPath, 'orca.yaml')
if (!existsSync(yamlPath)) {
return null
}
try {
const content = readFileSync(yamlPath, 'utf-8')
return parseOrcaYaml(content)
} catch {
return null
}
}
/**
* Check whether an orca.yaml exists for a repo.
*/
export function hasHooksFile(repoPath: string): boolean {
return existsSync(join(repoPath, 'orca.yaml'))
}
// Why: detect unrecognised keys so the UI can suggest an update instead of showing a "could not be parsed" error.
const RECOGNIZED_ORCA_YAML_KEYS = new Set([
'scripts',
'setupAgentStartupPolicy',
'issueCommand',
'defaultTabs',
'environmentRecipes',
'worktree'
])
/** True when `orca.yaml` has a top-level key this version of Orca does not handle. */
export function hasUnrecognizedOrcaYamlKeys(repoPath: string): boolean {
try {
const content = readFileSync(join(repoPath, 'orca.yaml'), 'utf-8')
for (const line of iterateLfScriptLines(content)) {
// Why: match bare `key:` at end-of-line too, since a mapping with a block value on the next line is valid YAML.
const m = line.match(/^([A-Za-z][A-Za-z0-9_-]*):(\s|$)/)
if (m != null && !RECOGNIZED_ORCA_YAML_KEYS.has(m[1])) {
return true
}
}
return false
} catch {
return false
}
}
export function getEffectiveHooks(repo: Repo, worktreePath?: string): OrcaHooks | null {
const hooksRoot = worktreePath ?? repo.path
return getEffectiveHooksFromConfig(repo, loadHooks(hooksRoot))
}
export function getSetupCommandSource(
repo: Repo,
worktreePath?: string
): { source: 'yaml' | 'local' | 'both'; command: string } | null {
const hooksRoot = worktreePath ?? repo.path
const yamlHooks = loadHooks(hooksRoot)
const yamlSetup = yamlHooks?.scripts.setup?.trim()
const localSetup = repo.hookSettings?.scripts.setup?.trim()
const rawPolicy = repo.hookSettings?.commandSourcePolicy
const policy = resolveHookCommandSourcePolicy(rawPolicy, {
hasLocalScript: Boolean(localSetup)
})
if (policy === 'local-only') {
return localSetup ? { source: 'local', command: localSetup } : null
}
if (policy === 'run-both' && yamlSetup && localSetup) {
return { source: 'both', command: `${yamlSetup}\n${localSetup}` }
}
if (yamlSetup) {
return { source: 'yaml', command: yamlSetup }
}
return null
}
/**
* Run a named hook script in the given working directory.
*/
export function runHook(
hookName: 'setup' | 'archive',
cwd: string,
repo: Repo,
hooksPath?: string,
projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget
): Promise<{ success: boolean; output: string }> {
const hooks = getEffectiveHooks(repo, hooksPath)
const script = hooks?.scripts[hookName]
if (!script) {
return Promise.resolve({ success: true, output: '' })
}
const runtimeTarget = getHookRuntimeTarget(projectRuntime)
const wslInfo = getHookWslContext(cwd, runtimeTarget)
if (wslInfo) {
// Why: hook scripts run inside WSL, so translate the ORCA_* Windows UNC paths to Linux paths.
const envVars = getSetupEnvVars(repo, cwd)
const wslEnv: Record<string, string> = {}
for (const [key, value] of Object.entries(envVars)) {
wslEnv[key] = toLinuxPath(value)
}
// Why: same unattended-git guard as the non-WSL branch below (issue
// #7652) — only the guard flags and any indexed git-config protocol are
// meant to reach the guest; askpass stays host-side, same as before.
const guardedEnv = promptGuardShellEnv(wslEnv)
const guestEnv: Record<string, string> = { ...wslEnv }
for (const [key, value] of Object.entries(guardedEnv)) {
if (
value !== undefined &&
(key === 'GIT_TERMINAL_PROMPT' ||
key === 'GCM_INTERACTIVE' ||
key.startsWith('GIT_CONFIG_'))
) {
guestEnv[key] = value
}
}
return runWslProcess({
distro: wslInfo.distro ?? undefined,
loginPath: 'preferred',
script,
// Why pinned: these are user-authored orca.yaml scripts and the native
// path runs /bin/bash. Defaulting to sh would fail bash-only hooks on WSL
// only -- a downgrade the user never asked for.
shell: 'bash',
cwd: wslInfo.linuxPath,
env: guestEnv,
timeoutMs: HOOK_TIMEOUT
})
.then((result) => {
if (result.timedOut) {
const message = `Hook timed out after ${HOOK_TIMEOUT}ms.`
console.error(`[hooks] ${hookName} hook failed in ${cwd}:`, message)
return { success: false, output: `${result.stdout}\n${result.stderr}\n${message}`.trim() }
}
if (result.code !== 0) {
const message = `Command failed with exit code ${result.code}.`
console.error(`[hooks] ${hookName} hook failed in ${cwd}:`, message)
return { success: false, output: `${result.stdout}\n${result.stderr}\n${message}`.trim() }
}
console.log(`[hooks] ${hookName} hook completed in ${cwd}`)
return { success: true, output: `${result.stdout}\n${result.stderr}`.trim() }
})
.catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error)
console.error(`[hooks] ${hookName} hook failed in ${cwd}:`, message)
return { success: false, output: message }
})
}
const shellHookEnv: NodeJS.ProcessEnv = { ...process.env, ...getSetupEnvVars(repo, cwd) }
dropIncoherentCondaActivationEnv(shellHookEnv)
return new Promise((resolve) => {
exec(
script,
{
cwd,
timeout: HOOK_TIMEOUT,
shell: getHookShell(),
// Why: hooks run unattended; block Git Credential Manager's interactive prompt while keeping cached auth (issue #7652).
env: promptGuardShellEnv(shellHookEnv)
},
(error, stdout, stderr) => {
if (error) {
console.error(`[hooks] ${hookName} hook failed in ${cwd}:`, error.message)
resolve({
success: false,
output: `${stdout}\n${stderr}\n${error.message}`.trim()
})
} else {
console.log(`[hooks] ${hookName} hook completed in ${cwd}`)
resolve({
success: true,
output: `${stdout}\n${stderr}`.trim()
})
}
}
)
})
}