Files
orca/src/shared/git-binary-compatibility.test.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

169 lines
5.8 KiB
TypeScript

import { execFile } from 'node:child_process'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import {
isUnsupportedMergeTreeMergeBaseError,
isUnsupportedMergeTreeWriteTreeError
} from './git-merge-tree-capability'
import { isForEachRefExcludeUnsupportedError } from './git-ref-command-capabilities'
import {
hasUnsupportedRevParsePathFormatEcho,
isUnsupportedWorktreeListZError
} from './git-worktree-command-capabilities'
import { gitCredentialPromptGuardEnv } from './git-credential-prompt-env'
const execFileAsync = promisify(execFile)
const image = process.env.ORCA_GIT_COMPAT_IMAGE
const binary = process.env.ORCA_GIT_COMPAT_BINARY
const expectedVersion = process.env.ORCA_GIT_COMPAT_VERSION
const describeBinaryCompatibility = image || binary ? describe : describe.skip
type GitResult = { stdout: string; stderr: string }
describeBinaryCompatibility('real Git binary compatibility', () => {
let repoPath = ''
let version = { major: 0, minor: 0 }
async function runGit(args: string[], env?: NodeJS.ProcessEnv): Promise<GitResult> {
if (image) {
const dockerUser =
typeof process.getuid === 'function' && typeof process.getgid === 'function'
? ['--user', `${process.getuid()}:${process.getgid()}`]
: []
return execFileAsync(
'docker',
[
'run',
'--rm',
'--network=none',
...dockerUser,
...Object.entries(env ?? {}).flatMap(([key, value]) =>
value === undefined ? [] : ['--env', `${key}=${value}`]
),
'-v',
`${repoPath}:/repo`,
'-w',
'/repo',
image,
'-c',
'safe.directory=/repo',
...args
],
{ maxBuffer: 2 * 1024 * 1024 }
)
}
return execFileAsync(binary!, args, {
cwd: repoPath,
env: env ? { ...process.env, ...env } : undefined,
maxBuffer: 2 * 1024 * 1024
})
}
function supports(major: number, minor: number): boolean {
return version.major > major || (version.major === major && version.minor >= minor)
}
async function expectPreferredOrRecognizedFallback(
args: string[],
expectedSupport: boolean,
recognizesUnsupported: (error: unknown) => boolean
): Promise<void> {
try {
await runGit(args)
expect(expectedSupport).toBe(true)
} catch (error) {
expect(expectedSupport).toBe(false)
expect(recognizesUnsupported(error)).toBe(true)
}
}
beforeAll(async () => {
repoPath = await mkdtemp(join(tmpdir(), 'orca-git-binary-compat-'))
const versionOutput = await runGit(['--version'])
expect(versionOutput.stdout).toContain(`git version ${expectedVersion}`)
const match = versionOutput.stdout.match(/git version (\d+)\.(\d+)/)
expect(match).not.toBeNull()
version = { major: Number(match![1]), minor: Number(match![2]) }
await runGit(['init', '-q'])
await runGit(['config', 'user.email', 'compatibility@example.invalid'])
await runGit(['config', 'user.name', 'Compatibility Test'])
await writeFile(join(repoPath, 'tracked.txt'), 'compatibility\n')
await runGit(['add', 'tracked.txt'])
await runGit(['commit', '-qm', 'initial'])
})
afterAll(async () => {
if (repoPath) {
await rm(repoPath, { recursive: true, force: true })
}
})
it('recognizes worktree-list and rev-parse compatibility boundaries', async () => {
await expectPreferredOrRecognizedFallback(
['worktree', 'list', '--porcelain', '-z'],
supports(2, 36),
isUnsupportedWorktreeListZError
)
await expect(runGit(['worktree', 'list', '--porcelain'])).resolves.toMatchObject({
stdout: expect.stringContaining('worktree ')
})
const preferred = await runGit([
'rev-parse',
'--path-format=absolute',
'--show-toplevel',
'--git-common-dir'
])
expect(hasUnsupportedRevParsePathFormatEcho(preferred.stdout)).toBe(!supports(2, 31))
await expect(
runGit(['rev-parse', '--show-toplevel', '--git-common-dir'])
).resolves.toBeDefined()
})
it('recognizes ref and merge-tree compatibility boundaries', async () => {
await expectPreferredOrRecognizedFallback(
['for-each-ref', '--format=%(refname)', '--exclude=refs/remotes/**/HEAD', '--count=10'],
supports(2, 42),
isForEachRefExcludeUnsupportedError
)
await expect(
runGit(['for-each-ref', '--format=%(refname)', '--count=10'])
).resolves.toBeDefined()
await expectPreferredOrRecognizedFallback(
['merge-tree', '--write-tree', 'HEAD', 'HEAD'],
supports(2, 38),
isUnsupportedMergeTreeWriteTreeError
)
if (supports(2, 38)) {
const head = (await runGit(['rev-parse', 'HEAD'])).stdout.trim()
const legacyArgs = ['merge-tree', '--write-tree', '--name-only', '-z', '--no-messages']
await expectPreferredOrRecognizedFallback(
[...legacyArgs, '--merge-base', head, head, head],
supports(2, 40),
isUnsupportedMergeTreeMergeBaseError
)
await expect(runGit([...legacyArgs, head, head])).resolves.toBeDefined()
}
})
it('degrades indexed credential config safely at the Git 2.31 boundary', async () => {
const guardEnv = gitCredentialPromptGuardEnv({}, 'linux')
await expect(runGit(['status', '--short'], guardEnv)).resolves.toBeDefined()
try {
const result = await runGit(['config', '--get', 'credential.interactive'], guardEnv)
expect(supports(2, 31)).toBe(true)
expect(result.stdout.trim()).toBe('false')
} catch {
// Git 2.25 ignores the indexed variables rather than rejecting commands;
// the scalar prompt guards still provide the baseline fail-fast behavior.
expect(supports(2, 31)).toBe(false)
}
})
})