Files
orca/src/main/hooks.ts
T
Brennan BensonandBrennan Benson 1a6abc87d1 Suppress Git Credential Manager OAuth popup loop in Orca-run git — clone, terminals/agents, setup hooks (fixes #7652) (#7986)
* Suppress Git Credential Manager OAuth popup on git clone (fixes #7652)

Orca's git runner disables the interactive credential prompt on every git
call that goes through gitExecFileAsync/gitStreamStdout, but the two raw
'git clone' spawns (desktop repos:clone and the runtime clone path) passed
no env, so they inherited process.env with no guard. On Windows a clone
that needs GitHub auth then makes Git Credential Manager pop its
'Connect to GitHub' OAuth window, and in a network-restricted intranet the
browser/device flow never completes while git's credential retry re-pops it.

Apply nonInteractiveGitEnv() to both clone spawns so the prompt is
suppressed (GCM_INTERACTIVE=never, credential.interactive=false,
GIT_TERMINAL_PROMPT=0). The credential *helper* is kept, so cached-token
clones for private repos still work; only the interactive fallback popup is
disabled and the clone fails fast with a clear error instead.

* Suppress GCM OAuth popup in agent terminals and setup hooks too (#7652)

The clone-spawn fix stopped Orca's own managed git from popping Git
Credential Manager, but git run in terminals and setup scripts inherited
process.env with no guard. That is the more likely source of the reported
loop: agents are told to run 'git pull --rebase'/'git fetch'/retry 'git
push' (preamble + conflict/push-failure prompts), and each retry re-pops
GCM's 'Connect to GitHub' window in a network-restricted intranet.

Apply the credential-prompt guard to:
- setup/archive/hook scripts (hooks.ts non-WSL exec env), which run
  unattended on worktree create/archive.
- the shared PTY host env (buildPtyHostEnv), via a small
  applyTerminalGitCredentialPromptGuard helper. Agent terminals are
  guarded unconditionally (they cannot dismiss a GUI popup); user
  terminals are guarded by default via the new
  terminalSuppressGitCredentialPrompt setting so power users can opt out.

The credential helper is kept, so cached gh auth still works; only the
interactive fallback prompt is disabled. Verified end-to-end in a real
Orca terminal (GIT_TERMINAL_PROMPT=0 + GCM_INTERACTIVE=never by default;
absent when the opt-out is set).

* Scope user-terminal credential guard to Windows, add settings toggle, forward guard into WSL (#7652)

* Retrigger PR checks (Actions dropped the synchronize dispatch for 57e7ce249)

* Keep shell locale out of the terminal/hook credential guard (#7652 review fix)

* Fix Fable review findings: guard WSL hook branch, wire settings search, catalog keyword keys, sparse-env askpass, one-shot agent classification (#7652)

* fix(terminal): harden Git credential popup guard

* test(pty): cover SSH credential guard setting

* fix(git): guard remote clones and setup runners

* fix(git): scope credential guards to unattended work

---------

Co-authored-by: Brennan Benson <brennanbenson@Brennans-MacBook-Pro.local>
2026-07-14 15:23:06 -07:00

696 lines
23 KiB
TypeScript

/* eslint-disable max-lines -- Why: hook parsing, layered issue-command resolution, and cross-platform runner setup share one execution surface, so keeping them together avoids subtle drift across create/read/write paths. */
import { readFileSync, existsSync, mkdirSync, writeFileSync, chmodSync, rmSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { exec, execFile } from 'node:child_process'
import { getDefaultRepoHookSettings } from '../shared/constants'
import { getRuntimePathBasename } from '../shared/cross-platform-path'
import { resolveHookCommandSourcePolicy } from '../shared/hook-command-source-policy'
import { shouldWaitForSetupBeforeAgentStartup } from '../shared/setup-agent-startup-policy'
import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../shared/terminal-git-credential-guard'
import { parseOrcaYaml } from '../shared/orca-yaml'
import { gitExecFileSync, promptGuardShellEnv } from './git/runner'
import { isWslPath, parseWslPath, toWindowsWslPath, toLinuxPath } from './wsl'
import type {
HookCommandSourcePolicy,
OrcaHooks,
Repo,
SetupDecision,
SetupRunPolicy,
WorktreeDefaultTabsLaunch,
WorktreeSetupLaunch
} from '../shared/types'
import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime'
const HOOK_TIMEOUT = 120_000 // 2 minutes
export type HookRuntimeTarget = {
wslDistro?: string | null
}
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: when a newer Orca release adds a top-level key to `orca.yaml` (like
// `issueCommand` was added here), older versions that don't recognise it will
// return `null` from `parseOrcaYaml` and show a confusing "could not be parsed"
// error. Detecting well-formed but unrecognised keys lets the UI suggest an
// update instead of implying the file is broken.
const RECOGNIZED_ORCA_YAML_KEYS = new Set([
'scripts',
'issueCommand',
'defaultTabs',
'environmentRecipes'
])
/**
* Return true when `orca.yaml` contains at least one top-level key that 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: bare `key:` at end-of-line (no trailing space) is valid YAML for
// a mapping with a block value on the next line. Match both forms so
// newer keys like `futureFeature:\n nested` are still detected.
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
}
}
// ─── Issue command files ────────────────────────────────────────────────
// Why: `orca.yaml` is the tracked, project-wide defaults surface, while
// `.orca/issue-command` remains the per-user override. Keeping the local file in
// `.orca/` lets users customize agent automation without editing committed config.
const ORCA_DIR = '.orca'
const ISSUE_COMMAND_FILENAME = 'issue-command'
export function getIssueCommandFilePath(repoPath: string): string {
return join(repoPath, ORCA_DIR, ISSUE_COMMAND_FILENAME)
}
export function getSharedIssueCommand(repoPath: string): string | null {
return loadHooks(repoPath)?.issueCommand?.trim() || null
}
export type ResolvedIssueCommand = {
localContent: string | null
sharedContent: string | null
effectiveContent: string | null
localFilePath: string
source: 'local' | 'shared' | 'none'
}
/**
* Resolve the GitHub issue command using local override first, then tracked repo config.
*/
export function readIssueCommand(repoPath: string): ResolvedIssueCommand {
const filePath = getIssueCommandFilePath(repoPath)
let localContent: string | null = null
if (existsSync(filePath)) {
try {
const content = readFileSync(filePath, 'utf-8').trim()
localContent = content || null
} catch {
localContent = null
}
}
const sharedContent = getSharedIssueCommand(repoPath)
const effectiveContent = localContent ?? sharedContent
return {
localContent,
sharedContent,
effectiveContent,
localFilePath: filePath,
source: localContent ? 'local' : sharedContent ? 'shared' : 'none'
}
}
/**
* Write the per-user issue command override to `{repoRoot}/.orca/issue-command`.
* Creates `.orca/` and ensures it is in `.gitignore` on first write.
* If content is empty, deletes only the override so the shared `orca.yaml`
* command becomes effective again.
*/
export function writeIssueCommand(repoPath: string, content: string): void {
const filePath = getIssueCommandFilePath(repoPath)
const trimmed = content.trim()
try {
if (!trimmed) {
rmSync(filePath, { force: true })
return
}
const orcaDir = join(repoPath, ORCA_DIR)
if (!existsSync(orcaDir)) {
mkdirSync(orcaDir, { recursive: true })
}
ensureOrcaDirIgnored(repoPath)
writeFileSync(filePath, `${trimmed}\n`, 'utf-8')
} catch (err) {
console.error('[hooks] Failed to write issue command:', err)
// Why: re-throw so the error propagates through the IPC handler to the
// renderer, which already has .catch() ready to surface write failures.
throw err
}
}
/**
* Ensure `.orca` is listed in the repo's `.gitignore` so the per-user
* directory is never accidentally committed.
*/
function ensureOrcaDirIgnored(repoPath: string): void {
const gitignorePath = join(repoPath, '.gitignore')
try {
if (existsSync(gitignorePath)) {
const content = readFileSync(gitignorePath, 'utf-8')
if (/^\.orca\/?$/m.test(content)) {
return
}
const separator = content.endsWith('\n') ? '' : '\n'
writeFileSync(gitignorePath, `${content}${separator}.orca\n`, 'utf-8')
} else {
writeFileSync(gitignorePath, '.orca\n', 'utf-8')
}
} catch {
console.warn('[hooks] Could not update .gitignore to exclude .orca')
}
}
function getEffectiveHookScript(
yamlScript: string | undefined,
localScript: string | undefined,
policy: HookCommandSourcePolicy
): string | undefined {
const shared = yamlScript?.trim()
const local = localScript?.trim()
if (policy === 'local-only') {
return local || undefined
}
if (policy === 'run-both') {
return [shared, local].filter(Boolean).join('\n') || undefined
}
return shared || undefined
}
export function getEffectiveHooksFromConfig(
repo: Repo,
yamlHooks: OrcaHooks | null
): OrcaHooks | null {
const localSetup = repo.hookSettings?.scripts.setup
const localArchive = repo.hookSettings?.scripts.archive
const rawPolicy = repo.hookSettings?.commandSourcePolicy
const setupPolicy = resolveHookCommandSourcePolicy(rawPolicy, {
hasLocalScript: Boolean(localSetup?.trim())
})
const archivePolicy = resolveHookCommandSourcePolicy(rawPolicy, {
hasLocalScript: Boolean(localArchive?.trim())
})
const setup = getEffectiveHookScript(yamlHooks?.scripts.setup, localSetup, setupPolicy)
const archive = getEffectiveHookScript(yamlHooks?.scripts.archive, localArchive, archivePolicy)
if (!setup && !archive) {
return null
}
// Why: committed `orca.yaml` and local Settings commands can intentionally
// coexist, but the source policy defines whether the committed file is an
// authoritative boundary, local settings are authoritative, or both run.
return {
scripts: {
...(setup ? { setup } : {}),
...(archive ? { archive } : {})
}
}
}
export function getEffectiveHooks(repo: Repo, worktreePath?: string): OrcaHooks | null {
const hooksRoot = worktreePath ?? repo.path
return getEffectiveHooksFromConfig(repo, loadHooks(hooksRoot))
}
export function getEffectiveSetupRunPolicy(repo: Repo): SetupRunPolicy {
return repo.hookSettings?.setupRunPolicy ?? getDefaultRepoHookSettings().setupRunPolicy!
}
export function shouldRunSetupForCreate(repo: Repo, decision: SetupDecision = 'inherit'): boolean {
if (decision === 'run') {
return true
}
if (decision === 'skip') {
return false
}
const policy = getEffectiveSetupRunPolicy(repo)
if (policy === 'ask') {
throw new Error('Setup decision required for this repository')
}
return policy === 'run-by-default'
}
export function getDefaultTabCommandTrustContent(hooks: OrcaHooks | null): string {
const commands = (hooks?.defaultTabs ?? [])
.map((tab, index) => {
const command = tab.command?.trim()
if (!command) {
return null
}
const label = tab.title ? ` ${tab.title}` : ''
return `# defaultTabs[${index + 1}]${label}\n${command}`
})
.filter((entry): entry is string => entry !== null)
return [hooks?.scripts.setup?.trim(), ...commands].filter(Boolean).join('\n\n')
}
export function getDefaultTabsLaunch(
hooks: OrcaHooks | null,
repo: Repo,
decision: SetupDecision = 'inherit'
): WorktreeDefaultTabsLaunch | undefined {
const tabs = hooks?.defaultTabs ?? []
if (tabs.length === 0) {
return undefined
}
const hasCommands = tabs.some((tab) => Boolean(tab.command?.trim()))
const sharedCommandPolicy = resolveHookCommandSourcePolicy(
repo.hookSettings?.commandSourcePolicy,
{
hasLocalScript: Boolean(repo.hookSettings?.scripts.setup?.trim())
}
)
// Why: default tab commands come from committed `orca.yaml`; a repo set to
// local-only may still use shared titles/colors, but must not execute them.
const canRunSharedCommands = sharedCommandPolicy !== 'local-only'
const runCommands =
hasCommands && canRunSharedCommands ? shouldRunSetupForCreate(repo, decision) : false
return { tabs, runCommands }
}
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
}
function getSetupEnvVars(repo: Repo, worktreePath: string): Record<string, string> {
return {
ORCA_ROOT_PATH: repo.path,
ORCA_WORKTREE_PATH: worktreePath,
ORCA_WORKSPACE_NAME: getRuntimePathBasename(worktreePath),
// Compat with conductor.json users
CONDUCTOR_ROOT_PATH: repo.path,
GHOSTX_ROOT_PATH: repo.path
}
}
function getGitPath(cwd: string, relativePath: string, runtimeTarget?: HookRuntimeTarget): string {
return gitExecFileSync(['rev-parse', '--git-path', relativePath], {
cwd,
...(runtimeTarget?.wslDistro ? { wslDistro: runtimeTarget.wslDistro } : {})
}).trim()
}
function getHookRuntimeTarget(
projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget
): HookRuntimeTarget | undefined {
if (!projectRuntime) {
return undefined
}
if ('status' in projectRuntime) {
if (projectRuntime.status === 'repair-required') {
return projectRuntime.repair.preferredRuntime.kind === 'wsl'
? { wslDistro: projectRuntime.repair.preferredRuntime.distro }
: undefined
}
return projectRuntime.runtime.kind === 'wsl'
? { wslDistro: projectRuntime.runtime.distro }
: undefined
}
return projectRuntime.wslDistro ? { wslDistro: projectRuntime.wslDistro } : undefined
}
function getHookWslContext(
cwd: string,
runtimeTarget?: HookRuntimeTarget
): { distro: string | null; linuxPath: string } | null {
const pathInfo = parseWslPath(cwd)
if (pathInfo) {
return pathInfo
}
const wslDistro = runtimeTarget?.wslDistro?.trim()
if (!wslDistro) {
return null
}
// Why: project runtime can route a normal Windows checkout through WSL; hooks
// must cd to the Linux view of that path rather than running in cmd.exe.
return {
distro: wslDistro,
linuxPath: toLinuxPath(cwd)
}
}
export function buildWindowsRunnerScript(script: string): string {
let runnerScript = '@echo off\r\nsetlocal EnableExtensions\r\n'
for (const rawLine of iterateLfScriptLines(script)) {
const command = rawLine.trim()
if (!command) {
runnerScript += '\r\n'
continue
}
// Why: setup commands often invoke `npm`/`pnpm`, which are batch files on
// Windows. Calling one batch file from another without `call` never returns
// to later lines, and plain newline-separated commands also keep running
// after failures. Wrap each line in `call` and bail on non-zero exit codes
// so the generated runner matches the fail-fast behavior of `set -e`.
runnerScript += `call ${command}\r\nif errorlevel 1 exit /b %errorlevel%\r\n`
}
return runnerScript
}
function* iterateLfScriptLines(script: string): Generator<string> {
let lineStart = 0
for (let index = 0; index < script.length; index++) {
if (script.charCodeAt(index) !== 10) {
continue
}
const lineEnd = index > lineStart && script.charCodeAt(index - 1) === 13 ? index - 1 : index
yield script.slice(lineStart, lineEnd)
lineStart = index + 1
}
if (lineStart <= script.length) {
yield script.slice(lineStart)
}
}
export function createSetupRunnerScript(
repo: Repo,
worktreePath: string,
script: string,
projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget
): WorktreeSetupLaunch {
return createWorktreeRunnerScript(
repo,
worktreePath,
script,
'setup-runner',
getHookRuntimeTarget(projectRuntime),
shouldWaitForSetupBeforeAgentStartup(repo.hookSettings?.setupAgentStartupPolicy)
)
}
export function getSetupRunnerEnvVars(repo: Repo, worktreePath: string): Record<string, string> {
return {
...getSetupEnvVars(repo, worktreePath),
// Why: the visible Setup terminal is still unattended automation; user
// terminal opt-out must not let its git commands open credential UI.
[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]: 'guard'
}
}
export function buildPosixRunnerScript(script: string): string {
return `#!/usr/bin/env bash\nset -e\n${normalizeCrlfScriptLineEndings(script)}\n`
}
function normalizeCrlfScriptLineEndings(script: string): string {
let crlfStart = script.indexOf('\r\n')
if (crlfStart === -1) {
return script
}
let normalized = script.slice(0, crlfStart)
let chunkStart = crlfStart + 2
normalized += '\n'
crlfStart = script.indexOf('\r\n', chunkStart)
while (crlfStart !== -1) {
normalized += script.slice(chunkStart, crlfStart)
normalized += '\n'
chunkStart = crlfStart + 2
crlfStart = script.indexOf('\r\n', chunkStart)
}
return `${normalized}${script.slice(chunkStart)}`
}
export function createIssueCommandRunnerScript(
repo: Repo,
worktreePath: string,
command: string,
projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget
): WorktreeSetupLaunch {
// Why: long issue-automation commands are user-visible shell input when
// written directly to the PTY, so terminal line editors can wrap or truncate
// them before execution. Writing the real command into a runner script keeps
// the shell startup path short and mirrors the already-stable setup runner
// flow instead of inventing a second launch mechanism.
return createWorktreeRunnerScript(
repo,
worktreePath,
command,
'issue-command-runner',
getHookRuntimeTarget(projectRuntime)
)
}
function createWorktreeRunnerScript(
repo: Repo,
worktreePath: string,
script: string,
runnerBaseName: 'setup-runner' | 'issue-command-runner',
runtimeTarget?: HookRuntimeTarget,
waitForAgentStartup?: boolean
): WorktreeSetupLaunch {
const envVars = getSetupRunnerEnvVars(repo, worktreePath)
// Why: WSL worktrees run on a Linux filesystem even though process.platform
// is 'win32'. Use bash scripts for WSL, .cmd for native Windows.
const wslWorktree = isWslPath(worktreePath) || Boolean(runtimeTarget?.wslDistro)
const useWindowsFormat = process.platform === 'win32' && !wslWorktree
// Why: linked git worktrees use a `.git` file that points at the real gitdir,
// so writing under `${worktreePath}/.git/...` fails. `git rev-parse --git-path`
// resolves the actual per-worktree git storage path safely across platforms.
const gitRelPath = useWindowsFormat ? `orca/${runnerBaseName}.cmd` : `orca/${runnerBaseName}.sh`
let runnerScriptPath = getGitPath(worktreePath, gitRelPath, runtimeTarget)
// Why: for WSL worktrees, getGitPath returns a Linux path (e.g. /home/user/...)
// because git runs inside WSL. Convert it to a Windows UNC path so mkdirSync
// and writeFileSync (which run on Windows) can access it.
if (wslWorktree) {
const wslInfo = getHookWslContext(worktreePath, runtimeTarget)
if (wslInfo?.distro) {
runnerScriptPath = toWindowsWslPath(runnerScriptPath.trim(), wslInfo.distro)
}
}
mkdirSync(dirname(runnerScriptPath), { recursive: true })
if (useWindowsFormat) {
writeFileSync(runnerScriptPath, buildWindowsRunnerScript(script), 'utf-8')
} else {
writeFileSync(runnerScriptPath, buildPosixRunnerScript(script), 'utf-8')
// Why: chmod via UNC paths to WSL filesystem is supported by Windows and
// sets the execute bit correctly inside WSL.
chmodSync(runnerScriptPath, 0o755)
}
// Why: when the worktree is on WSL, env vars like ORCA_ROOT_PATH and
// ORCA_WORKTREE_PATH contain Windows UNC paths. The setup script runs
// inside WSL bash, so translate them to Linux paths.
if (wslWorktree) {
for (const key of Object.keys(envVars)) {
envVars[key] = toLinuxPath(envVars[key])
}
}
return {
runnerScriptPath,
envVars,
...(waitForAgentStartup === true ? { waitForAgentStartup: true } : {})
}
}
/**
* 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: use execFile('wsl.exe', [...]) instead of exec() to bypass the
// Windows shell (cmd.exe). exec() always routes through a shell, and
// cmd.exe doesn't understand single-quote escaping — it would mangle
// paths/scripts containing %, ^, &, |, etc.
const escapedCwd = wslInfo.linuxPath.replace(/'/g, "'\\''")
const escapedScript = script.replace(/'/g, "'\\''")
const bashCmd = `cd '${escapedCwd}' && ${escapedScript}`
// Why: translate ORCA_ROOT_PATH / ORCA_WORKTREE_PATH to Linux paths so
// hook scripts that reference $ORCA_WORKTREE_PATH get usable paths
// inside WSL, not Windows UNC paths.
const envVars = getSetupEnvVars(repo, cwd)
const wslEnv: Record<string, string> = {}
for (const [key, value] of Object.entries(envVars)) {
wslEnv[key] = toLinuxPath(value)
}
return new Promise((resolve) => {
let child: ReturnType<typeof execFile> | null = null
let settled = false
const finish = (error: Error | null, stdout = '', stderr = ''): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
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()
})
}
}
// Why: Node's execFile timeout only signals wsl.exe; if no callback
// arrives, hook setup/archive must still unblock after HOOK_TIMEOUT.
const timeout = setTimeout(() => {
child?.kill()
finish(new Error(`Hook timed out after ${HOOK_TIMEOUT}ms.`))
}, HOOK_TIMEOUT)
try {
const distroArgs = wslInfo.distro ? ['-d', wslInfo.distro] : []
child = execFile(
'wsl.exe',
[...distroArgs, '--', 'bash', '-c', bashCmd],
{
timeout: HOOK_TIMEOUT,
encoding: 'utf-8',
// Why: same unattended-git guard as the non-WSL branch below
// (issue #7652) — WSL repos are the likeliest to hit the GCM
// popup, and the guard's WSLENV registration is what carries it
// across the wsl.exe boundary into the distro.
env: promptGuardShellEnv({ ...process.env, ...wslEnv })
},
(error, stdout, stderr) => {
finish(error ?? null, stdout, stderr)
}
)
} catch (error) {
finish(error instanceof Error ? error : new Error(String(error)))
}
})
}
return new Promise((resolve) => {
exec(
script,
{
cwd,
timeout: HOOK_TIMEOUT,
shell: getHookShell(),
// Why: setup/archive hooks run unattended, so a `git fetch`/`submodule
// update` inside one must never make Git Credential Manager pop its
// "Connect to GitHub" OAuth window on Windows and loop when the network
// can't complete it (issue #7652). The guard keeps the credential
// helper, so cached auth still works; only the interactive prompt dies.
env: promptGuardShellEnv({
...process.env,
...getSetupEnvVars(repo, cwd)
})
},
(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()
})
}
}
)
})
}