Files
orca/src/shared/agent-process-recognition.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

337 lines
11 KiB
TypeScript

import { getTuiAgentDetectCommands, TUI_AGENT_CONFIG } from './tui-agent-config'
import type { AgentType } from './agent-status-types'
import type { TuiAgent } from './types'
import { filterHeadlessOneShotAgentCommand } from './agent-headless-command'
import { getFirstCommandToken } from './command-token-scanner'
export type RecognizedAgentProcess = { agent: TuiAgent; processName: string }
const PROCESS_EXTENSION_RE = /\.(?:exe|cmd|bat|ps1)$/i
const INTERPRETER_SCRIPT_EXTENSION_RE = /\.(?:js|mjs|cjs)$/i
const PYTHON_SCRIPT_EXTENSION_RE = /\.(?:py|pyw)$/i
function normalizeProcessName(
processName: string | null | undefined,
options: { stripInterpreterScriptExtension?: boolean } = {}
): string {
if (!processName) {
return ''
}
const unquoted = processName.trim().replace(/^["']|["']$/g, '')
const basename = unquoted.split(/[\\/]/).pop() ?? unquoted
const withoutProcessExtension = basename.toLowerCase().replace(PROCESS_EXTENSION_RE, '')
if (options.stripInterpreterScriptExtension === true) {
return withoutProcessExtension.replace(INTERPRETER_SCRIPT_EXTENSION_RE, '')
}
return withoutProcessExtension
}
const STATIC_INTERPRETER_PROCESS_NAMES = new Set([
'node',
'python',
'python3',
'bash',
'zsh',
'sh',
'fish',
'pwsh',
'powershell'
])
const FOREGROUND_AGENT_WRAPPER_PROCESS_NAMES = new Set(['node', 'python', 'python3'])
const PYTHON_PROCESS_RE = /^python(?:\d+(?:\.\d+)*)?$/
const INTERPRETER_OPTIONS_WITH_VALUE = new Set([
'-r',
'--require',
'--import',
'--loader',
'--experimental-loader'
])
const INTERPRETER_OPTIONS_WITH_INLINE_SOURCE = new Set(['-e', '--eval', '-p', '--print', '--check'])
const NODE_PACKAGE_SCRIPT_ENTRYPOINTS: Record<string, readonly string[]> = {
codex: ['node_modules/@openai/codex/'],
gemini: ['node_modules/@google/gemini-cli/']
}
const PYTHON_SCRIPT_ENTRYPOINT_DIRECTORIES = ['/bin/', '/scripts/', '/site-packages/']
const PROCESS_TO_AGENT = new Map<string, TuiAgent>()
const AGENT_TYPE_IDS = new Set<TuiAgent>()
for (const [agent, config] of Object.entries(TUI_AGENT_CONFIG) as [
TuiAgent,
(typeof TUI_AGENT_CONFIG)[TuiAgent]
][]) {
AGENT_TYPE_IDS.add(agent)
for (const candidate of [
config.expectedProcess,
...getTuiAgentDetectCommands(config),
getFirstCommandToken(config.launchCmd)
]) {
const normalized = normalizeProcessName(candidate)
if (normalized) {
// Why: claude-agent-teams is an Orca wrapper whose child process is the
// real `claude` binary. Do not let wrapper configs overwrite canonical
// CLI ownership for the same foreground process name.
if (!PROCESS_TO_AGENT.has(normalized)) {
PROCESS_TO_AGENT.set(normalized, agent)
}
}
}
}
function agentForNormalizedProcess(normalized: string): TuiAgent | undefined {
const exact = PROCESS_TO_AGENT.get(normalized)
if (exact) {
return exact
}
// Why: node-pty can report Codex's packaged platform binary
// (for example codex-aarch64-ap) instead of the launch command.
if (normalized.startsWith('codex-')) {
return PROCESS_TO_AGENT.get('codex')
}
if (normalized.startsWith('grok-')) {
return PROCESS_TO_AGENT.get('grok')
}
return undefined
}
function tokenizeCommandLine(commandLine: string): string[] {
const tokens: string[] = []
let current = ''
let quote: '"' | "'" | null = null
let escaped = false
for (let index = 0; index < commandLine.length; index += 1) {
const char = commandLine[index]
if (escaped) {
current += char
escaped = false
continue
}
if (char === '\\' && quote !== "'") {
const next = commandLine[index + 1]
if (next && (/\s/.test(next) || next === '"' || next === "'" || next === '\\')) {
escaped = true
continue
}
}
if ((char === '"' || char === "'") && quote === null) {
quote = char
continue
}
if (quote === char) {
quote = null
continue
}
if (/\s/.test(char) && quote === null) {
if (current) {
tokens.push(current)
current = ''
}
continue
}
current += char
}
if (current) {
tokens.push(current)
}
return tokens
}
function tokenLooksExecutable(token: string, index: number, firstNormalized: string): boolean {
if (index === 0) {
return true
}
if (!isInterpreterProcessName(firstNormalized)) {
return false
}
// Why: only inspect interpreter script paths. Prompt text can mention other
// agents ("compare opencode vs orca"), and treating every argv token as an
// executable would reintroduce the substring-style false identity class that
// foreground-process detection is meant to avoid.
return token.includes('/') || token.includes('\\') || PROCESS_EXTENSION_RE.test(token)
}
function isInterpreterProcessName(normalized: string): boolean {
return STATIC_INTERPRETER_PROCESS_NAMES.has(normalized) || PYTHON_PROCESS_RE.test(normalized)
}
const isPythonProcessName = (normalized: string): boolean => PYTHON_PROCESS_RE.test(normalized)
const optionName = (token: string): string => token.split('=', 1)[0] ?? ''
function findInterpreterEntrypointToken(tokens: string[], firstNormalized: string): string | null {
if (!isInterpreterProcessName(firstNormalized)) {
return null
}
for (let index = 1; index < tokens.length; index += 1) {
const token = tokens[index]
if (token === '--') {
continue
}
if (isPythonProcessName(firstNormalized) && token === '-m') {
return tokens[index + 1] ?? null
}
if (token.startsWith('-')) {
const name = optionName(token)
if (INTERPRETER_OPTIONS_WITH_INLINE_SOURCE.has(name)) {
return null
}
if (INTERPRETER_OPTIONS_WITH_VALUE.has(name) && name === token) {
index += 1
}
continue
}
if (tokenLooksExecutable(token, index, firstNormalized)) {
return token
}
}
return null
}
function comparablePath(token: string): string {
return token
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\\/g, '/')
.toLowerCase()
}
function recognizeNodeScriptEntrypoint(token: string): RecognizedAgentProcess | null {
const normalized = normalizeProcessName(token, { stripInterpreterScriptExtension: true })
const markers = NODE_PACKAGE_SCRIPT_ENTRYPOINTS[normalized]
if (!markers) {
return null
}
const path = comparablePath(token)
if (!markers.some((marker) => path.includes(marker))) {
return null
}
const agent = agentForNormalizedProcess(normalized)
if (!agent) {
return null
}
return { agent, processName: normalized }
}
function recognizePythonModule(
moduleName: string | null | undefined
): RecognizedAgentProcess | null {
if (!moduleName || moduleName.startsWith('-')) {
return null
}
const normalized = moduleName.split('.', 1)[0]?.toLowerCase() ?? ''
const agent = agentForNormalizedProcess(normalized)
if (!agent) {
return null
}
return { agent, processName: normalized }
}
function recognizePythonScriptEntrypoint(token: string): RecognizedAgentProcess | null {
const path = comparablePath(token)
if (!PYTHON_SCRIPT_EXTENSION_RE.test(path)) {
return null
}
if (!PYTHON_SCRIPT_ENTRYPOINT_DIRECTORIES.some((marker) => path.includes(marker))) {
return null
}
const basename = path.split('/').pop() ?? ''
const normalized = basename.replace(PYTHON_SCRIPT_EXTENSION_RE, '')
const agent = agentForNormalizedProcess(normalized)
if (!agent) {
return null
}
return { agent, processName: normalized }
}
function recognizePythonEntrypoint(
tokens: string[],
entrypoint: string
): RecognizedAgentProcess | null {
const moduleFlagIndex = tokens.indexOf('-m')
if (moduleFlagIndex > 0) {
return recognizePythonModule(tokens[moduleFlagIndex + 1])
}
return recognizeAgentProcess(entrypoint) ?? recognizePythonScriptEntrypoint(entrypoint)
}
export function isExpectedAgentProcess(
processName: string | null | undefined,
expectedProcess: string
): boolean {
const normalizedProcess = normalizeProcessName(processName)
const normalizedExpected = normalizeProcessName(expectedProcess)
if (!normalizedProcess || !normalizedExpected) {
return false
}
return (
normalizedProcess === normalizedExpected ||
normalizedProcess.startsWith(`${normalizedExpected}.`)
)
}
export function recognizeAgentProcess(
processName: string | null | undefined
): RecognizedAgentProcess | null {
const normalized = normalizeProcessName(processName)
const agent = agentForNormalizedProcess(normalized)
if (!agent) {
return null
}
return { agent, processName: normalized }
}
export function recognizeAgentProcessFromCommandLine(
commandLine: string | null | undefined,
// Why: TUI consumers (status hooks, shell shadows) filter out headless
// one-shots (`claude -p …`); non-interactivity guards include them — a
// one-shot agent can't answer a prompt either.
options?: { includeHeadlessOneShot?: boolean }
): RecognizedAgentProcess | null {
if (!commandLine) {
return null
}
const keep = options?.includeHeadlessOneShot === true
const tokens = tokenizeCommandLine(commandLine)
const firstNormalized = normalizeProcessName(tokens[0])
let direct = recognizeAgentProcess(tokens[0])
// Why: the generic Orca CLI is not an agent; only this subcommand launches its TUI mode.
if (direct?.agent === 'claude-agent-teams' && tokens[1]?.toLowerCase() !== 'claude-teams') {
direct = null
}
const directRecognition = keep ? direct : filterHeadlessOneShotAgentCommand(direct, tokens)
if (directRecognition) {
return directRecognition
}
const entrypoint = findInterpreterEntrypointToken(tokens, firstNormalized)
if (!entrypoint) {
return null
}
const viaEntrypoint = isPythonProcessName(firstNormalized)
? recognizePythonEntrypoint(tokens, entrypoint)
: (recognizeAgentProcess(entrypoint) ?? recognizeNodeScriptEntrypoint(entrypoint))
if (
viaEntrypoint?.agent === 'claude-agent-teams' &&
tokens[tokens.indexOf(entrypoint, 1) + 1]?.toLowerCase() !== 'claude-teams'
) {
return null
}
return keep ? viaEntrypoint : filterHeadlessOneShotAgentCommand(viaEntrypoint, tokens)
}
export function isAgentForegroundWrapperProcess(processName: string | null | undefined): boolean {
const normalized = normalizeProcessName(processName)
return (
FOREGROUND_AGENT_WRAPPER_PROCESS_NAMES.has(normalized) || PYTHON_PROCESS_RE.test(normalized)
)
}
export function isRecognizedAgentType(agentType: AgentType | null | undefined): boolean {
if (typeof agentType !== 'string') {
return false
}
return (
AGENT_TYPE_IDS.has(agentType as TuiAgent) ||
agentForNormalizedProcess(normalizeProcessName(agentType)) !== undefined
)
}