fix(terminal): harden Git credential popup guard

This commit is contained in:
Brennan Benson
2026-07-13 17:56:24 -07:00
parent 976ef8c1da
commit dad513d6d6
28 changed files with 1631 additions and 197 deletions
+169
View File
@@ -68,6 +68,12 @@ vi.mock('../providers/agent-foreground-process', () => ({
}))
import { createPtySubprocess, checkPtySpawnHealth } from './pty-subprocess'
import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types'
import {
ensureTerminalGitCredentialPromptGuard,
TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV,
TERMINAL_GIT_CREDENTIAL_GUARD_STATE_ENV
} from '../../shared/terminal-git-credential-guard'
const ORCA_SHELL_WRAPPER_ENV = [
'ORCA_ATTRIBUTION_SHIM_DIR',
@@ -186,6 +192,169 @@ describe('createPtySubprocess', () => {
)
})
it('appends Git prompt guards after the detached daemon inherited config', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
const previousWslEnv = process.env.WSLENV
const savedGitConfigEnv = Object.fromEntries(
Object.entries(process.env).filter(([key]) =>
/^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)$/.test(key)
)
)
for (const key of Object.keys(process.env)) {
if (/^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)$/.test(key)) {
delete process.env[key]
}
}
process.env.GIT_CONFIG_COUNT = '1'
process.env.GIT_CONFIG_KEY_0 = 'core.quotePath'
process.env.GIT_CONFIG_VALUE_0 = 'false'
process.env.WSLENV = 'DAEMON_ONLY/p'
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
createPtySubprocess({
sessionId: 'guarded-git-config',
cols: 80,
rows: 24,
env: {
COMSPEC: CMD_ABS,
GIT_TERMINAL_PROMPT: '0',
GCM_INTERACTIVE: 'never'
}
})
const spawnEnv = spawnMock.mock.calls.at(-1)?.[2]?.env as Record<string, string>
expect(spawnEnv.GIT_CONFIG_COUNT).toBe('3')
expect(spawnEnv.GIT_CONFIG_KEY_0).toBe('core.quotePath')
expect(spawnEnv.GIT_CONFIG_VALUE_0).toBe('false')
expect(spawnEnv.GIT_CONFIG_KEY_1).toBe('credential.interactive')
expect(spawnEnv.GIT_CONFIG_KEY_2).toBe('credential.guiPrompt')
expect((spawnEnv.WSLENV ?? '').split(':')).toContain('DAEMON_ONLY/p')
expect((spawnEnv.WSLENV ?? '').split(':')).toContain('GIT_CONFIG_KEY_2')
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
for (const key of Object.keys(process.env)) {
if (/^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)$/.test(key)) {
delete process.env[key]
}
}
Object.assign(process.env, savedGitConfigEnv)
if (previousWslEnv === undefined) {
delete process.env.WSLENV
} else {
process.env.WSLENV = previousWslEnv
}
}
})
it('lets explicit wire Git config replace daemon config before appending the guard', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
const savedGitConfigEnv = Object.fromEntries(
Object.entries(process.env).filter(([key]) =>
/^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)$/.test(key)
)
)
for (const key of Object.keys(process.env)) {
if (/^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)$/.test(key)) {
delete process.env[key]
}
}
process.env.GIT_CONFIG_COUNT = '3'
process.env.GIT_CONFIG_KEY_0 = 'core.quotePath'
process.env.GIT_CONFIG_VALUE_0 = 'false'
process.env.GIT_CONFIG_KEY_1 = 'base.one'
process.env.GIT_CONFIG_VALUE_1 = 'one'
process.env.GIT_CONFIG_KEY_2 = 'base.two'
process.env.GIT_CONFIG_VALUE_2 = 'two'
try {
createPtySubprocess({
sessionId: 'explicit-guarded-git-config',
cols: 80,
rows: 24,
env: {
SHELL: '/bin/bash',
GIT_TERMINAL_PROMPT: '0',
GCM_INTERACTIVE: 'never',
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'http.proxy',
GIT_CONFIG_VALUE_0: 'http://proxy.invalid'
}
})
const spawnEnv = spawnMock.mock.calls.at(-1)?.[2]?.env as Record<string, string>
expect(spawnEnv.GIT_CONFIG_COUNT).toBe('3')
expect(spawnEnv.GIT_CONFIG_KEY_0).toBe('http.proxy')
expect(spawnEnv.GIT_CONFIG_VALUE_0).toBe('http://proxy.invalid')
expect(Object.values(spawnEnv)).not.toContain('core.quotePath')
expect(Object.values(spawnEnv)).not.toContain('base.one')
expect(Object.values(spawnEnv)).not.toContain('base.two')
expect(spawnEnv.GIT_CONFIG_KEY_1).toBe('credential.interactive')
expect(spawnEnv.GIT_CONFIG_KEY_2).toBe('credential.guiPrompt')
expect(spawnEnv.GIT_CONFIG_KEY_3).toBeUndefined()
} finally {
for (const key of Object.keys(process.env)) {
if (/^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)$/.test(key)) {
delete process.env[key]
}
}
Object.assign(process.env, savedGitConfigEnv)
}
})
it('clears an Orca-owned guard inherited by the detached daemon when the user opts out', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
const inheritedGuard: Record<string, string> = {}
ensureTerminalGitCredentialPromptGuard(inheritedGuard, 'linux')
const saved = Object.fromEntries(
Object.keys(inheritedGuard).map((key) => [key, process.env[key]])
)
Object.assign(process.env, inheritedGuard)
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
try {
createPtySubprocess({
sessionId: 'opted-out-inherited-guard',
cols: 80,
rows: 24,
env: {
SHELL: '/bin/bash',
[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]: 'clear'
}
})
const spawnEnv = spawnMock.mock.calls.at(-1)?.[2]?.env as Record<string, string>
expect(spawnEnv.GIT_TERMINAL_PROMPT).toBeUndefined()
expect(spawnEnv.GCM_INTERACTIVE).toBeUndefined()
expect(spawnEnv.GIT_CONFIG_COUNT).toBeUndefined()
expect(spawnEnv[TERMINAL_GIT_CREDENTIAL_GUARD_STATE_ENV]).toBeUndefined()
expect(spawnEnv[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]).toBeUndefined()
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
for (const key of Object.keys(inheritedGuard)) {
if (saved[key] === undefined) {
delete process.env[key]
} else {
process.env[key] = saved[key]
}
}
}
})
it('uses a new daemon protocol for post-merge Git guard behavior', () => {
expect(PROTOCOL_VERSION).toBeGreaterThan(21)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(21)
})
it('resolves a missing Unix default before spawning node-pty', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
+30 -2
View File
@@ -34,6 +34,12 @@ import { removeInheritedNoColor } from '../pty/terminal-color-env'
import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env'
import { parseWslPath } from '../wsl'
import { addWslEnvKeys } from '../wsl-env'
import { mergeGitConfigEnvProtocol } from '../../shared/git-credential-prompt-env'
import {
clearTerminalGitCredentialPromptGuard,
ensureTerminalGitCredentialPromptGuard,
TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV
} from '../../shared/terminal-git-credential-guard'
import { getWslContextFromSessionId } from './wsl-session-context'
import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env'
import {
@@ -82,6 +88,28 @@ const PTY_SPAWN_HEALTH_TIMEOUT_MS = 4_000
const PTY_SPAWN_HEALTH_RETRY_ATTEMPTS = 2
const PENDING_PRE_LISTENER_DATA_MAX_CHARS = 512 * 1024
function composeGuardedDaemonGitConfigEnv(
env: Record<string, string>,
explicitEnv: Record<string, string> | undefined
): void {
const policy = explicitEnv?.[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]
delete env[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]
if (policy === 'clear') {
clearTerminalGitCredentialPromptGuard(env)
return
}
if (
policy !== 'guard' &&
(explicitEnv?.GIT_TERMINAL_PROMPT !== '0' || explicitEnv.GCM_INTERACTIVE !== 'never')
) {
return
}
// Why: the daemon can outlive Electron, so only its process.env is the
// authoritative inherited config. The raw env merge already gives an
// explicit wire protocol normal override semantics; append only the guard.
ensureTerminalGitCredentialPromptGuard(env, process.platform)
}
export type PtySubprocessOptions = {
sessionId: string
cols: number
@@ -543,8 +571,7 @@ function spawnDaemonPtyWithWindowsFallback(args: {
export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandle {
const size = normalizePtySize(opts.cols, opts.rows)
const env: Record<string, string> = {
...process.env,
...opts.env,
...mergeGitConfigEnvProtocol(process.env, opts.env),
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
TERM_PROGRAM: 'Orca',
@@ -561,6 +588,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
// restores clickable refs like `owner/repo#123` / `PR#123`.
FORCE_HYPERLINK: '1'
} as Record<string, string>
composeGuardedDaemonGitConfigEnv(env, opts.env)
for (const key of opts.envToDelete ?? []) {
delete env[key]
}
+2 -2
View File
@@ -16,9 +16,9 @@ import type { TuiAgent } from '../../shared/types'
// when daemon-baked behavior cannot be delivered by on-disk wrapper refresh.
// Why: bump when adding daemon wire behavior so same-version old daemons do
// not silently accept the handshake and then reject new RPCs.
export const PROTOCOL_VERSION = 21
export const PROTOCOL_VERSION = 22
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21
] as const
// ─── Session State Machine ──────────────────────────────────────────
+42
View File
@@ -13,6 +13,7 @@ import {
redirectPortedHostnameToEnv,
untranslatedGitOutputEnv
} from './runner'
import { mergeGitConfigEnvProtocol } from '../../shared/git-credential-prompt-env'
// Reads git config injected via the GIT_CONFIG_COUNT/KEY/VALUE env protocol
// back into a plain key→value map so tests can assert on it directly.
@@ -190,6 +191,47 @@ describe('appendGitConfigEnv', () => {
expect(env.GIT_CONFIG_KEY_1).toBe('credential.guiPrompt')
expect(env.GIT_CONFIG_VALUE_1).toBe('false')
})
it.each(['bogus', '-1', '0', String(Number.MAX_SAFE_INTEGER)])(
'does not overwrite dangling caller config when count is %s',
(count) => {
const original = {
GIT_CONFIG_COUNT: count,
GIT_CONFIG_KEY_0: 'user.key',
GIT_CONFIG_VALUE_0: 'caller-value'
}
expect(appendGitConfigEnv(original, [['credential.interactive', 'false']])).toEqual(original)
}
)
it('does not append to an incomplete indexed-config protocol', () => {
const original = { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'user.key' }
expect(appendGitConfigEnv(original, [['credential.interactive', 'false']])).toEqual(original)
})
})
describe('mergeGitConfigEnvProtocol', () => {
it('replaces inherited indexed config atomically when an override has a smaller count', () => {
const env = mergeGitConfigEnvProtocol(
{
GIT_CONFIG_COUNT: '2',
GIT_CONFIG_KEY_0: 'base.zero',
GIT_CONFIG_VALUE_0: 'zero',
GIT_CONFIG_KEY_1: 'base.one',
GIT_CONFIG_VALUE_1: 'one'
},
{
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'override.zero',
GIT_CONFIG_VALUE_0: 'override'
}
)
expect(env.GIT_CONFIG_COUNT).toBe('1')
expect(env.GIT_CONFIG_KEY_0).toBe('override.zero')
expect(env.GIT_CONFIG_KEY_1).toBeUndefined()
expect(env.GIT_CONFIG_VALUE_1).toBeUndefined()
})
})
describe('promptGuardGitEnv credential-interactivity disable (STA-1292)', () => {
+7 -60
View File
@@ -30,6 +30,10 @@ import {
} from './gh-rate-limit-breaker'
import { getDefaultWslDistro, parseWslPath, toWindowsWslPath, type WslPathInfo } from '../wsl'
import { addWslEnvKeys } from '../wsl-env'
import {
appendGitConfigEnv,
gitCredentialPromptGuardEnv
} from '../../shared/git-credential-prompt-env'
import { getSpawnArgsForWindows, isWindowsBatchScript, resolveWindowsCommand } from '../win32-utils'
import {
buildWslLoginShellCommand,
@@ -559,20 +563,7 @@ export function gitOptionalLocksDisabledEnv(
* already present in `env` so we never clobber config a caller injected the
* same way.
*/
export function appendGitConfigEnv(
env: NodeJS.ProcessEnv,
entries: readonly (readonly [key: string, value: string])[]
): NodeJS.ProcessEnv {
const parsed = Number.parseInt(env.GIT_CONFIG_COUNT ?? '', 10)
const base = Number.isInteger(parsed) && parsed > 0 ? parsed : 0
const next = { ...env }
entries.forEach(([key, value], index) => {
next[`GIT_CONFIG_KEY_${base + index}`] = key
next[`GIT_CONFIG_VALUE_${base + index}`] = value
})
next.GIT_CONFIG_COUNT = String(base + entries.length)
return next
}
export { appendGitConfigEnv }
/**
* Pin Orca-spawned git to untranslated English output so stderr/progress
@@ -585,47 +576,11 @@ export function untranslatedGitOutputEnv(env: NodeJS.ProcessEnv = process.env):
return { ...env, ...UNTRANSLATED_GIT_OUTPUT_ENV }
}
const GIT_CONFIG_WSLENV_KEY_RE = /^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)$/
export function promptGuardGitEnv(
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform
): NodeJS.ProcessEnv {
const next = appendGitConfigEnv(
{
...untranslatedGitOutputEnv(env),
GIT_TERMINAL_PROMPT: '0',
GIT_ASKPASS: env.GIT_ASKPASS ?? '',
SSH_ASKPASS: env.SSH_ASKPASS ?? '',
// Why: Git Credential Manager ignores GIT_TERMINAL_PROMPT / GIT_ASKPASS and
// pops a GUI on first auth — the Windows worktree-create hang (STA-1292).
// `never` suppresses the prompt while still serving cached credentials.
GCM_INTERACTIVE: 'never'
},
// Why: disable only the *interactive* credential prompt, NOT the helper
// itself — an empty credential.helper would break cached-credential auth for
// private repos. Harmless on macOS/Linux (no GCM) and on the SSH path.
[
['credential.interactive', 'false'],
['credential.guiPrompt', 'false']
]
)
if (platform === 'win32') {
// Why: wsl.exe only imports env vars named in WSLENV, so WSL-routed git
// would never see the guard — and WSL setups commonly use the Windows
// credential manager as git's helper, which pops the same OAuth window
// (issue #7652). Askpass vars are deliberately not forwarded: a Windows
// askpass path is meaningless inside the distro. GIT_CONFIG_* must be
// forwarded all-or-nothing (including caller-preexisting entries): a
// GIT_CONFIG_COUNT that crosses without every KEY_n/VALUE_n below it makes
// git error out on "bogus count".
addWslEnvKeys(next, [
'GIT_TERMINAL_PROMPT',
'GCM_INTERACTIVE',
...Object.keys(next).filter((key) => GIT_CONFIG_WSLENV_KEY_RE.test(key))
])
}
return next
return gitCredentialPromptGuardEnv(untranslatedGitOutputEnv(env), platform)
}
/**
@@ -639,15 +594,7 @@ export function promptGuardShellEnv(
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform
): NodeJS.ProcessEnv {
const next = promptGuardGitEnv(env, platform)
for (const key of Object.keys(UNTRANSLATED_GIT_OUTPUT_ENV)) {
if (env[key] === undefined) {
delete next[key]
} else {
next[key] = env[key]
}
}
return next
return gitCredentialPromptGuardEnv(env, platform)
}
/**
+28
View File
@@ -876,6 +876,14 @@ describe('registerPtyHandlers', () => {
expect(env.TERM_PROGRAM).toBe('Orca')
})
it('keeps indexed Git prompt guards in a local agent terminal env', async () => {
const env = await spawnAndGetEnv(undefined, undefined, undefined, undefined, 'claude')
expect(env.GIT_TERMINAL_PROMPT).toBe('0')
expect(env.GCM_INTERACTIVE).toBe('never')
expect(Object.values(env)).toContain('credential.interactive')
expect(Object.values(env)).toContain('credential.guiPrompt')
})
it('advertises OSC 8 hyperlink support via FORCE_HYPERLINK', async () => {
// Why: the supports-hyperlinks npm package hard-codes a TERM_PROGRAM
// allowlist (iTerm.app / WezTerm / vscode) and reports false for
@@ -2050,6 +2058,26 @@ describe('registerPtyHandlers', () => {
}
})
it('defers indexed Git prompt guards from the daemon wire environment', async () => {
const env = await daemonSpawnAndGetEnv(
{
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'http.proxy',
GIT_CONFIG_VALUE_0: 'http://proxy.invalid'
},
undefined,
undefined,
undefined,
{ command: 'claude' }
)
expect(env.GIT_TERMINAL_PROMPT).toBe('0')
expect(env.GCM_INTERACTIVE).toBe('never')
expect(env.GIT_CONFIG_COUNT).toBe('1')
expect(env.GIT_CONFIG_KEY_0).toBe('http.proxy')
expect(env.GIT_CONFIG_KEY_1).toBeUndefined()
})
it('passes the minted sessionId through to provider.spawn and host env setup', async () => {
const daemonSpawn = setupDaemonAdapter()
handlers.clear()
+11 -1
View File
@@ -566,6 +566,9 @@ export type BuildPtyHostEnvOptions = {
* (issue #7652). No effect on non-Windows hosts; agent terminals are always
* guarded on every platform regardless of this flag. */
suppressUserTerminalGitCredentialPrompt?: boolean
/** Keep indexed Git config off the sparse daemon wire; the daemon appends
* guard entries after merging its authoritative inherited environment. */
deferGitConfigGuardToDaemon?: boolean
}
function readInheritedPath(baseEnv: Record<string, string>): string {
@@ -861,7 +864,8 @@ export function buildPtyHostEnv(
// agent retries git in a network-restricted intranet (issue #7652).
applyTerminalGitCredentialPromptGuard(baseEnv, {
launchCommand: launchCommandHint,
suppressUserTerminalPrompt: opts.suppressUserTerminalGitCredentialPrompt !== false
suppressUserTerminalPrompt: opts.suppressUserTerminalGitCredentialPrompt !== false,
deferGitConfigGuardToHost: opts.deferGitConfigGuardToDaemon
})
const shouldPrepareOmpShadow = piAgentKind === 'omp' || !hasLaunchCommand
@@ -3047,6 +3051,7 @@ export function registerPtyHandlers(
wslDistro: codexSelectionTarget.runtime === 'wsl' ? codexSelectionTarget.wslDistro : null,
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
networkProxySettings: getSettings?.(),
deferGitConfigGuardToDaemon: true,
suppressUserTerminalGitCredentialPrompt:
getSettings?.()?.terminalSuppressGitCredentialPrompt ?? true
})
@@ -3061,6 +3066,8 @@ export function registerPtyHandlers(
rows: args.rows,
cwd,
env,
suppressUserTerminalGitCredentialPrompt:
getSettings?.()?.terminalSuppressGitCredentialPrompt ?? true,
...(isMintedSessionId ? { isNewSession: true } : {})
}
spawnOptions.envToDelete = mergePtyEnvDeletions(
@@ -3904,6 +3911,7 @@ export function registerPtyHandlers(
codexSelectionTarget.runtime === 'wsl' ? codexSelectionTarget.wslDistro : null,
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
networkProxySettings: getSettings?.(),
deferGitConfigGuardToDaemon: true,
suppressUserTerminalGitCredentialPrompt:
getSettings?.()?.terminalSuppressGitCredentialPrompt ?? true
})
@@ -3947,6 +3955,8 @@ export function registerPtyHandlers(
rows: args.rows,
cwd,
env: spawnEnv,
suppressUserTerminalGitCredentialPrompt:
getSettings?.()?.terminalSuppressGitCredentialPrompt ?? true,
...(isMintedSessionId ? { isNewSession: true } : {})
}
if (combinedEnvToDelete) {
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
import { applyTerminalGitCredentialPromptGuard } from './terminal-git-credential-guard'
import {
applyTerminalGitCredentialPromptGuard,
TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV,
TERMINAL_GIT_CREDENTIAL_GUARD_STATE_ENV
} from './terminal-git-credential-guard'
// The load-bearing markers against Git Credential Manager's OAuth popup.
function isGuarded(env: Record<string, string>): boolean {
@@ -45,6 +49,7 @@ describe('applyTerminalGitCredentialPromptGuard', () => {
expect(wslenvKeys).toContain('GIT_CONFIG_COUNT')
expect(wslenvKeys).toContain('GIT_CONFIG_KEY_0')
expect(wslenvKeys).toContain('GIT_CONFIG_VALUE_0')
expect(wslenvKeys).toContain(TERMINAL_GIT_CREDENTIAL_GUARD_STATE_ENV)
// Windows askpass paths are meaningless inside a distro.
expect(wslenvKeys).not.toContain('GIT_ASKPASS')
expect(wslenvKeys).not.toContain('SSH_ASKPASS')
@@ -65,7 +70,8 @@ describe('applyTerminalGitCredentialPromptGuard', () => {
applyTerminalGitCredentialPromptGuard(sparse, {
launchCommand: 'claude',
suppressUserTerminalPrompt: true,
platform: 'win32'
platform: 'win32',
deferGitConfigGuardToHost: true
})
expect(sparse.GIT_ASKPASS).toBeUndefined()
expect(sparse.SSH_ASKPASS).toBeUndefined()
@@ -74,9 +80,403 @@ describe('applyTerminalGitCredentialPromptGuard', () => {
applyTerminalGitCredentialPromptGuard(withAskpass, {
launchCommand: 'claude',
suppressUserTerminalPrompt: true,
platform: 'win32'
platform: 'win32',
deferGitConfigGuardToHost: true
})
expect(withAskpass.GIT_ASKPASS).toBe('C:\\feeder.exe')
expect(sparse[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]).toBe('guard')
})
it('defers only indexed config guards for a daemon while keeping fail-fast markers', () => {
const sparse: Record<string, string> = {
PATH: '/usr/bin',
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'core.quotePath',
GIT_CONFIG_VALUE_0: 'false'
}
applyTerminalGitCredentialPromptGuard(sparse, {
launchCommand: 'claude',
suppressUserTerminalPrompt: true,
platform: 'win32',
deferGitConfigGuardToHost: true
})
expect(sparse.GIT_TERMINAL_PROMPT).toBe('0')
expect(sparse.GCM_INTERACTIVE).toBe('never')
expect(sparse.GIT_CONFIG_COUNT).toBe('1')
expect(sparse.GIT_CONFIG_KEY_0).toBe('core.quotePath')
expect(sparse.GIT_CONFIG_KEY_1).toBeUndefined()
expect(sparse.WSLENV).toBeUndefined()
expect(sparse[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]).toBe('guard')
})
it('restores only Orca-owned values when a nested Windows terminal opts out', () => {
const original: Record<string, string> = {
PATH: '/usr/bin',
GIT_TERMINAL_PROMPT: '1',
GCM_INTERACTIVE: 'force',
GIT_ASKPASS: 'C:\\user-askpass.exe',
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'core.quotePath',
GIT_CONFIG_VALUE_0: 'false',
WSLENV: 'USER_VALUE/p'
}
const env = { ...original }
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: 'claude',
suppressUserTerminalPrompt: false,
platform: 'win32'
})
expect(env[TERMINAL_GIT_CREDENTIAL_GUARD_STATE_ENV]).toBeDefined()
expect(env.GIT_CONFIG_COUNT).toBe('3')
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: '/bin/zsh',
suppressUserTerminalPrompt: false,
platform: 'win32'
})
expect(env).toEqual(original)
})
it('clears an inherited Orca guard from a non-Windows user terminal', () => {
const env: Record<string, string> = { PATH: '/usr/bin' }
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: 'claude',
suppressUserTerminalPrompt: false,
platform: 'linux'
})
expect(isGuarded(env)).toBe(true)
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: '/bin/zsh',
suppressUserTerminalPrompt: true,
platform: 'linux'
})
expect(env).toEqual({ PATH: '/usr/bin' })
})
it('reasserts a damaged inherited guard and later restores external changes', () => {
const env: Record<string, string> = { PATH: '/usr/bin' }
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: 'claude',
suppressUserTerminalPrompt: false,
platform: 'win32'
})
env.GIT_TERMINAL_PROMPT = '1'
env.GIT_CONFIG_VALUE_0 = 'true'
env.WSLENV = (env.WSLENV ?? '')
.split(':')
.filter((token) => token !== 'GCM_INTERACTIVE')
.join(':')
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: 'codex',
suppressUserTerminalPrompt: false,
platform: 'win32'
})
expect(isGuarded(env)).toBe(true)
expect(env.GIT_CONFIG_COUNT).toBe('3')
expect(env.GIT_CONFIG_KEY_1).toBe('credential.interactive')
expect(env.GIT_CONFIG_VALUE_1).toBe('false')
expect((env.WSLENV ?? '').split(':')).toContain('GCM_INTERACTIVE')
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: '/bin/zsh',
suppressUserTerminalPrompt: false,
platform: 'win32'
})
expect(env.GIT_TERMINAL_PROMPT).toBe('1')
expect(env.GIT_CONFIG_COUNT).toBe('1')
expect(env.GIT_CONFIG_VALUE_0).toBe('true')
expect(Object.values(env)).not.toContain('credential.guiPrompt')
})
it('reasserts after a later indexed entry overrides the owned guard', () => {
const env: Record<string, string> = { PATH: '/usr/bin' }
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: 'claude',
suppressUserTerminalPrompt: false,
platform: 'linux'
})
env.GIT_CONFIG_COUNT = '3'
env.GIT_CONFIG_KEY_2 = 'credential.interactive'
env.GIT_CONFIG_VALUE_2 = 'true'
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: 'codex',
suppressUserTerminalPrompt: false,
platform: 'linux'
})
expect(env.GIT_CONFIG_COUNT).toBe('3')
expect(env.GIT_CONFIG_KEY_0).toBe('credential.interactive')
expect(env.GIT_CONFIG_VALUE_0).toBe('true')
expect(env.GIT_CONFIG_KEY_1).toBe('credential.interactive')
expect(env.GIT_CONFIG_VALUE_1).toBe('false')
expect(env.GIT_CONFIG_KEY_2).toBe('credential.guiPrompt')
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: '/bin/zsh',
suppressUserTerminalPrompt: false,
platform: 'linux'
})
expect(env.GIT_CONFIG_COUNT).toBe('1')
expect(env.GIT_CONFIG_VALUE_0).toBe('true')
})
it('removes untouched owned config while preserving a partially modified entry', () => {
const env: Record<string, string> = { PATH: '/usr/bin' }
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: 'claude',
suppressUserTerminalPrompt: false,
platform: 'linux'
})
env.GIT_CONFIG_VALUE_0 = 'true'
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: 'codex',
suppressUserTerminalPrompt: false,
platform: 'linux'
})
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: '/bin/zsh',
suppressUserTerminalPrompt: false,
platform: 'linux'
})
expect(env.GIT_CONFIG_COUNT).toBe('1')
expect(env.GIT_CONFIG_KEY_0).toBe('credential.interactive')
expect(env.GIT_CONFIG_VALUE_0).toBe('true')
expect(Object.values(env)).not.toContain('credential.guiPrompt')
})
it.each([false, true])(
'transfers WSL forwarding when caller repurposes an owned slot (later token: %s)',
(addLaterToken) => {
const env: Record<string, string> = { PATH: '/usr/bin' }
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: 'claude',
suppressUserTerminalPrompt: false,
platform: 'win32'
})
env.GIT_CONFIG_KEY_0 = 'user.key'
env.GIT_CONFIG_VALUE_0 = 'caller-value'
if (addLaterToken) {
env.WSLENV += ':LATER/u'
}
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: '/bin/zsh',
suppressUserTerminalPrompt: false,
platform: 'win32'
})
expect(env.GIT_CONFIG_COUNT).toBe('1')
expect(env.GIT_CONFIG_KEY_0).toBe('user.key')
const wslenv = (env.WSLENV ?? '').split(':')
expect(wslenv).toContain('GIT_CONFIG_COUNT')
expect(wslenv).toContain('GIT_CONFIG_KEY_0')
expect(wslenv).toContain('GIT_CONFIG_VALUE_0')
expect(wslenv).not.toContain('GIT_CONFIG_KEY_1')
expect(wslenv).not.toContain('GIT_CONFIG_VALUE_1')
if (addLaterToken) {
expect(wslenv).toContain('LATER/u')
}
}
)
it('does not add WSL count without a complete caller forwarding protocol', () => {
const env: Record<string, string> = {
PATH: '/usr/bin',
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'user.key',
GIT_CONFIG_VALUE_0: 'caller-value',
WSLENV: 'PRE/u'
}
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: 'claude',
suppressUserTerminalPrompt: false,
platform: 'win32'
})
env.WSLENV += ':LATER/u'
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: '/bin/zsh',
suppressUserTerminalPrompt: false,
platform: 'win32'
})
expect(env.WSLENV).toBe('PRE/u:LATER/u')
})
it('upgrades scalar-only ownership when caller indexed config becomes valid', () => {
const env: Record<string, string> = {
PATH: '/usr/bin',
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'user.key'
}
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: 'claude',
suppressUserTerminalPrompt: false,
platform: 'win32'
})
expect(env.GIT_CONFIG_COUNT).toBe('1')
env.GIT_CONFIG_VALUE_0 = 'repaired'
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: 'codex',
suppressUserTerminalPrompt: false,
platform: 'win32'
})
expect(env.GIT_CONFIG_COUNT).toBe('3')
expect(env.GIT_CONFIG_KEY_1).toBe('credential.interactive')
expect(env.GIT_CONFIG_KEY_2).toBe('credential.guiPrompt')
expect((env.WSLENV ?? '').split(':')).toContain('GIT_CONFIG_KEY_2')
})
it('keeps the WSL count token when clearing before caller-appended config', () => {
const env: Record<string, string> = { PATH: '/usr/bin' }
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: 'claude',
suppressUserTerminalPrompt: false,
platform: 'win32'
})
env.GIT_CONFIG_COUNT = '3'
env.GIT_CONFIG_KEY_2 = 'user.key'
env.GIT_CONFIG_VALUE_2 = 'caller-value'
env.WSLENV = `${env.WSLENV}:GIT_CONFIG_KEY_2:GIT_CONFIG_VALUE_2`
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: '/bin/zsh',
suppressUserTerminalPrompt: false,
platform: 'win32'
})
expect(env.GIT_CONFIG_COUNT).toBe('1')
expect(env.GIT_CONFIG_KEY_0).toBe('user.key')
expect(env.GIT_CONFIG_VALUE_0).toBe('caller-value')
const wslenv = (env.WSLENV ?? '').split(':')
expect(wslenv).toContain('GIT_CONFIG_COUNT')
expect(wslenv).toContain('GIT_CONFIG_KEY_0')
expect(wslenv).toContain('GIT_CONFIG_VALUE_0')
})
it('preserves caller WSL token variants while removing exact Orca-owned tokens', () => {
const env: Record<string, string> = {
PATH: '/usr/bin',
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'user.before',
GIT_CONFIG_VALUE_0: 'before',
WSLENV: 'PRE/p:GIT_CONFIG_KEY_1/p:GIT_CONFIG_VALUE_1/l'
}
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: 'claude',
suppressUserTerminalPrompt: false,
platform: 'win32'
})
env.GIT_CONFIG_COUNT = '4'
env.GIT_CONFIG_KEY_3 = 'user.after'
env.GIT_CONFIG_VALUE_3 = 'after'
env.WSLENV += ':GIT_CONFIG_KEY_3/u:GIT_CONFIG_VALUE_3/w:GIT_CONFIG_KEY_2/p'
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: '/bin/zsh',
suppressUserTerminalPrompt: false,
platform: 'win32'
})
expect(env.GIT_CONFIG_COUNT).toBe('2')
expect(env.GIT_CONFIG_KEY_0).toBe('user.before')
expect(env.GIT_CONFIG_KEY_1).toBe('user.after')
const wslenv = (env.WSLENV ?? '').split(':')
expect(wslenv).toContain('GIT_CONFIG_COUNT')
expect(wslenv).toContain('GIT_CONFIG_KEY_1/p')
expect(wslenv).toContain('GIT_CONFIG_VALUE_1/l')
expect(wslenv).toContain('GIT_CONFIG_KEY_1/u')
expect(wslenv).toContain('GIT_CONFIG_VALUE_1/w')
expect(wslenv).toContain('GIT_CONFIG_KEY_2/p')
expect(wslenv).not.toContain(TERMINAL_GIT_CREDENTIAL_GUARD_STATE_ENV)
})
it('keeps scalar guards when externally removed config makes indexed state ambiguous', () => {
const env: Record<string, string> = { PATH: '/usr/bin' }
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: 'claude',
suppressUserTerminalPrompt: false,
platform: 'linux'
})
delete env.GIT_CONFIG_KEY_0
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: 'codex',
suppressUserTerminalPrompt: false,
platform: 'linux'
})
expect(isGuarded(env)).toBe(true)
expect(env.GIT_ASKPASS).toBe('')
expect(env.SSH_ASKPASS).toBe('')
expect(env.GIT_CONFIG_COUNT).toBe('2')
expect(env.GIT_CONFIG_KEY_0).toBeUndefined()
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: '/bin/zsh',
suppressUserTerminalPrompt: false,
platform: 'linux'
})
expect(env.GIT_TERMINAL_PROMPT).toBeUndefined()
expect(env.GIT_CONFIG_COUNT).toBe('2')
expect(env.GIT_CONFIG_KEY_0).toBeUndefined()
})
it.each(['bogus', '-1', '0'])(
'round-trips ambiguous indexed config with count %s without overwriting it',
(count) => {
const original: Record<string, string> = {
PATH: '/usr/bin',
GIT_CONFIG_COUNT: count,
GIT_CONFIG_KEY_0: 'user.key',
GIT_CONFIG_VALUE_0: 'caller-value',
WSLENV: 'USER_VALUE/p'
}
const env = { ...original }
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: 'claude',
suppressUserTerminalPrompt: false,
platform: 'win32'
})
expect(isGuarded(env)).toBe(true)
expect(env.GIT_CONFIG_COUNT).toBe(count)
expect(env.GIT_CONFIG_KEY_0).toBe('user.key')
expect((env.WSLENV ?? '').split(':')).not.toContain('GIT_CONFIG_COUNT')
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: '/bin/zsh',
suppressUserTerminalPrompt: false,
platform: 'win32'
})
expect(env).toEqual(original)
}
)
it('sends a clear policy when a detached host must remove its inherited guard', () => {
const env: Record<string, string> = { PATH: '/usr/bin' }
applyTerminalGitCredentialPromptGuard(env, {
launchCommand: '/bin/zsh',
suppressUserTerminalPrompt: false,
platform: 'win32',
deferGitConfigGuardToHost: true
})
expect(env).toEqual({
PATH: '/usr/bin',
[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]: 'clear'
})
})
it('never rewrites the terminal locale — the git-runner locale pins must not leak into a shell', () => {
+5 -54
View File
@@ -1,54 +1,5 @@
import { promptGuardShellEnv } from '../git/runner'
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
/**
* Disable git's interactive credential prompt for a terminal's environment so a
* git operation that needs GitHub auth cannot make the OS credential helper
* (Git Credential Manager on Windows) pop its "Connect to GitHub" OAuth window
* — which, in a network-restricted intranet, can never complete and gets
* re-triggered in a loop by git's credential retry (issue #7652).
*
* The credential *helper* is kept, so cached-token auth still works; only the
* interactive fallback prompt is suppressed. Agent terminals are always guarded
* on every platform (an agent can't answer a prompt, so failing fast beats
* hanging). User terminals are only guarded on Windows terminal hosts — the
* popup is a Windows credential-manager behavior, and on other platforms the
* guard would only take working tty prompts away from an interactive user —
* and the user can opt out via settings.
*
* Mutates `env` in place to match how the PTY host assembles its environment.
*/
export function applyTerminalGitCredentialPromptGuard(
env: Record<string, string>,
opts: {
launchCommand?: string | null
suppressUserTerminalPrompt: boolean
/** Injectable for tests; defaults to the spawning host's platform. */
platform?: NodeJS.Platform
}
): void {
// Why: include headless one-shots (`claude -p …`) — those can answer a
// credential prompt even less than a TUI agent can.
const isAgentTerminal = Boolean(
recognizeAgentProcessFromCommandLine(opts.launchCommand, { includeHeadlessOneShot: true })
)
const platform = opts.platform ?? process.platform
if (!isAgentTerminal && (!opts.suppressUserTerminalPrompt || platform !== 'win32')) {
return
}
// Why: the shell variant — a terminal env is the user's whole environment,
// so the git-runner locale pins (issue #7808) must not leak into it.
for (const [key, value] of Object.entries(promptGuardShellEnv(env, platform))) {
if (typeof value !== 'string') {
continue
}
// Why: the guard materializes GIT_ASKPASS/SSH_ASKPASS='' when absent.
// Daemon spawns pass a sparse wire env here and merge process.env later —
// a materialized '' would clobber a daemon-inherited askpass that the
// preserve-when-set design (see nonInteractiveGitEnv docs) means to keep.
if ((key === 'GIT_ASKPASS' || key === 'SSH_ASKPASS') && !(key in env)) {
continue
}
env[key] = value
}
}
export {
applyTerminalGitCredentialPromptGuard,
TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV,
TERMINAL_GIT_CREDENTIAL_GUARD_STATE_ENV
} from '../../shared/terminal-git-credential-guard'
@@ -429,6 +429,48 @@ describe('LocalPtyProvider', () => {
expect(spawnCall[2].env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
})
it('drops stale inherited Git config indices behind a smaller explicit count', async () => {
const keys = [
'GIT_CONFIG_COUNT',
'GIT_CONFIG_KEY_0',
'GIT_CONFIG_VALUE_0',
'GIT_CONFIG_KEY_1',
'GIT_CONFIG_VALUE_1'
] as const
const saved = Object.fromEntries(keys.map((key) => [key, process.env[key]]))
process.env.GIT_CONFIG_COUNT = '2'
process.env.GIT_CONFIG_KEY_0 = 'base.zero'
process.env.GIT_CONFIG_VALUE_0 = 'zero'
process.env.GIT_CONFIG_KEY_1 = 'base.one'
process.env.GIT_CONFIG_VALUE_1 = 'one'
try {
await provider.spawn({
cols: 80,
rows: 24,
env: {
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'override.zero',
GIT_CONFIG_VALUE_0: 'override'
}
})
const spawnEnv = spawnMock.mock.calls.at(-1)?.[2]?.env as Record<string, string>
expect(spawnEnv.GIT_CONFIG_COUNT).toBe('1')
expect(spawnEnv.GIT_CONFIG_KEY_0).toBe('override.zero')
expect(spawnEnv.GIT_CONFIG_KEY_1).toBeUndefined()
expect(spawnEnv.GIT_CONFIG_VALUE_1).toBeUndefined()
} finally {
for (const key of keys) {
if (saved[key] === undefined) {
delete process.env[key]
} else {
process.env[key] = saved[key]
}
}
}
})
it('does not inherit AppImage runtime env into Linux PTY shells', async () => {
const saved = {
APPIMAGE: process.env.APPIMAGE,
+2 -2
View File
@@ -58,6 +58,7 @@ import { readWindowsConptyProcessIds } from './windows-conpty-process-membership
import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery'
import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from './pty-default-cwd'
import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query'
import { mergeGitConfigEnvProtocol } from '../../shared/git-credential-prompt-env'
const PANE_IDENTITY_ENV_KEYS = [
'ORCA_PANE_KEY',
@@ -465,8 +466,7 @@ export class LocalPtyProvider implements IPtyProvider {
validateWorkingDirectory(validationCwd)
const spawnEnv: Record<string, string> = {
...process.env,
...args.env,
...mergeGitConfigEnvProtocol(process.env, args.env),
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
TERM_PROGRAM: 'Orca',
@@ -67,6 +67,21 @@ describe('SshPtyProvider', () => {
})
})
it('forwards the user-terminal credential guard opt-out to the relay host', async () => {
mux.request.mockResolvedValue({ id: 'pty-guard-setting' })
await provider.spawn({
cols: 120,
rows: 40,
suppressUserTerminalGitCredentialPrompt: false
})
expect(mux.request).toHaveBeenCalledWith(
'pty.spawn',
expect.objectContaining({ suppressUserTerminalGitCredentialPrompt: false })
)
})
it('forwards pane identity as relay metadata on fresh spawn', async () => {
mux.request.mockResolvedValue({ id: 'pty-2' })
+5
View File
@@ -150,6 +150,11 @@ export class SshPtyProvider implements IPtyProvider {
cwd: opts.cwd,
env: this.withRemoteCliBridgeEnv(opts.env, opts.envToDelete),
...(opts.envToDelete?.length ? { envToDelete: opts.envToDelete } : {}),
...(opts.suppressUserTerminalGitCredentialPrompt !== undefined
? {
suppressUserTerminalGitCredentialPrompt: opts.suppressUserTerminalGitCredentialPrompt
}
: {}),
// Why: the relay's plugin-overlay env augmenter needs to know which
// Pi-compatible agent is being launched, while commandDelivery tells it
// whether to submit the command itself for runtime-owned background PTYs.
+3
View File
@@ -60,6 +60,9 @@ export type PtySpawnOptions = {
cwd?: string
env?: Record<string, string>
envToDelete?: string[]
/** Remote hosts use their own platform when deciding whether a user shell
* receives the Windows credential-popup guard. */
suppressUserTerminalGitCredentialPrompt?: boolean
command?: string
commandDelivery?: 'renderer' | 'provider'
startupCommandDelivery?: StartupCommandDelivery
+1 -17
View File
@@ -1,17 +1 @@
export function addWslEnvKeys(
env: Record<string, string | undefined>,
keys: readonly string[]
): void {
const existing = env.WSLENV ?? process.env.WSLENV ?? ''
const tokens = existing.split(':').filter(Boolean)
const tokenNames = new Set(tokens.map((token) => token.split('/')[0]))
for (const key of keys) {
if (!tokenNames.has(key)) {
tokens.push(key)
tokenNames.add(key)
}
}
env.WSLENV = tokens.join(':')
}
export { addWslEnvKeys } from '../shared/wsl-env'
+162
View File
@@ -10,6 +10,10 @@ import {
resolveSetupAgentSequenceLaunchCommand,
SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV
} from '../shared/setup-agent-sequencing'
import {
ensureTerminalGitCredentialPromptGuard,
TERMINAL_GIT_CREDENTIAL_GUARD_STATE_ENV
} from '../shared/terminal-git-credential-guard'
const { mockPtySpawn, mockPtyInstance } = vi.hoisted(() => ({
mockPtySpawn: vi.fn(),
@@ -174,6 +178,100 @@ describe('PtyHandler', () => {
expect(handler.activePtyCount).toBe(1)
})
it('guards SSH agent terminals after merging the relay inherited Git config', async () => {
const gitConfigKeys = [
'GIT_CONFIG_COUNT',
'GIT_CONFIG_KEY_0',
'GIT_CONFIG_VALUE_0',
'GIT_CONFIG_KEY_1',
'GIT_CONFIG_VALUE_1',
'GIT_CONFIG_KEY_2',
'GIT_CONFIG_VALUE_2'
] as const
const saved = Object.fromEntries(gitConfigKeys.map((key) => [key, process.env[key]]))
process.env.GIT_CONFIG_COUNT = '3'
process.env.GIT_CONFIG_KEY_0 = 'core.quotePath'
process.env.GIT_CONFIG_VALUE_0 = 'false'
process.env.GIT_CONFIG_KEY_1 = 'base.one'
process.env.GIT_CONFIG_VALUE_1 = 'one'
process.env.GIT_CONFIG_KEY_2 = 'base.two'
process.env.GIT_CONFIG_VALUE_2 = 'two'
try {
await dispatcher.callRequest('pty.spawn', {
command: 'claude',
suppressUserTerminalGitCredentialPrompt: false,
env: {
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'http.proxy',
GIT_CONFIG_VALUE_0: 'http://proxy.invalid'
}
})
const spawnEnv = mockPtySpawn.mock.calls[0]?.[2]?.env as Record<string, string>
expect(spawnEnv.GIT_TERMINAL_PROMPT).toBe('0')
expect(spawnEnv.GCM_INTERACTIVE).toBe('never')
expect(spawnEnv.GIT_CONFIG_COUNT).toBe('3')
expect(spawnEnv.GIT_CONFIG_KEY_0).toBe('http.proxy')
expect(spawnEnv.GIT_CONFIG_KEY_1).toBe('credential.interactive')
expect(spawnEnv.GIT_CONFIG_KEY_2).toBe('credential.guiPrompt')
expect(spawnEnv.GIT_CONFIG_KEY_3).toBeUndefined()
} finally {
for (const key of gitConfigKeys) {
if (saved[key] === undefined) {
delete process.env[key]
} else {
process.env[key] = saved[key]
}
}
}
})
it('guards Windows SSH user terminals while honoring the forwarded opt-out', async () => {
const originalPlatform = process.platform
const savedTerminalPrompt = process.env.GIT_TERMINAL_PROMPT
const savedGcmInteractive = process.env.GCM_INTERACTIVE
delete process.env.GIT_TERMINAL_PROMPT
delete process.env.GCM_INTERACTIVE
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
await dispatcher.callRequest('pty.spawn', {
suppressUserTerminalGitCredentialPrompt: true
})
const guardedEnv = mockPtySpawn.mock.calls[0]?.[2]?.env as Record<string, string>
expect(guardedEnv.GIT_TERMINAL_PROMPT).toBe('0')
expect(guardedEnv.GCM_INTERACTIVE).toBe('never')
expect((guardedEnv.WSLENV ?? '').split(':')).toContain('GIT_CONFIG_COUNT')
mockPtySpawn.mockClear()
await dispatcher.callRequest('pty.spawn', {
env: guardedEnv,
suppressUserTerminalGitCredentialPrompt: false
})
const optedOutEnv = mockPtySpawn.mock.calls[0]?.[2]?.env as Record<string, string>
expect(optedOutEnv.GIT_TERMINAL_PROMPT).toBeUndefined()
expect(optedOutEnv.GCM_INTERACTIVE).toBeUndefined()
const state = (await dispatcher.callRequest('pty.serialize', { ids: ['pty-2'] })) as string
expect(JSON.parse(state)[0]?.gitCredentialPromptGuarded).toBe(false)
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
if (savedTerminalPrompt === undefined) {
delete process.env.GIT_TERMINAL_PROMPT
} else {
process.env.GIT_TERMINAL_PROMPT = savedTerminalPrompt
}
if (savedGcmInteractive === undefined) {
delete process.env.GCM_INTERACTIVE
} else {
process.env.GCM_INTERACTIVE = savedGcmInteractive
}
}
})
it('uses an explicit shell override and falls back to the default shell otherwise', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', {
@@ -1473,6 +1571,70 @@ describe('PtyHandler', () => {
expect(callArgs.env.TERM_PROGRAM).toBe('Orca')
})
it('revive preserves the credential guard chosen for an SSH agent terminal', async () => {
await dispatcher.callRequest('pty.spawn', {
command: 'claude',
suppressUserTerminalGitCredentialPrompt: false
})
const state = (await dispatcher.callRequest('pty.serialize', { ids: ['pty-1'] })) as string
expect(JSON.parse(state)[0]?.gitCredentialPromptGuarded).toBe(true)
handler.dispose()
mockPtySpawn.mockClear()
dispatcher = createMockDispatcher()
handler = new PtyHandler(dispatcher as unknown as RelayDispatcher)
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
try {
await dispatcher.callRequest('pty.revive', { state })
} finally {
killSpy.mockRestore()
}
const revivedEnv = mockPtySpawn.mock.calls[0]?.[2]?.env as Record<string, string>
expect(revivedEnv.GIT_TERMINAL_PROMPT).toBe('0')
expect(revivedEnv.GCM_INTERACTIVE).toBe('never')
expect(Object.values(revivedEnv)).toContain('credential.interactive')
expect(Object.values(revivedEnv)).toContain('credential.guiPrompt')
})
it('revive clears a relay guard inherited by an opted-out user terminal', async () => {
const inheritedGuard: Record<string, string> = {}
ensureTerminalGitCredentialPromptGuard(inheritedGuard, 'linux')
const saved = Object.fromEntries(
Object.keys(inheritedGuard).map((key) => [key, process.env[key]])
)
Object.assign(process.env, inheritedGuard)
const state = JSON.stringify([
{
id: 'pty-guard-opt-out',
pid: process.pid,
cols: 80,
rows: 24,
cwd: process.cwd(),
gitCredentialPromptGuarded: false
}
])
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
try {
await dispatcher.callRequest('pty.revive', { state })
const revivedEnv = mockPtySpawn.mock.calls[0]?.[2]?.env as Record<string, string>
expect(revivedEnv.GIT_TERMINAL_PROMPT).toBeUndefined()
expect(revivedEnv.GCM_INTERACTIVE).toBeUndefined()
expect(revivedEnv.GIT_CONFIG_COUNT).toBeUndefined()
expect(revivedEnv[TERMINAL_GIT_CREDENTIAL_GUARD_STATE_ENV]).toBeUndefined()
} finally {
killSpy.mockRestore()
for (const key of Object.keys(inheritedGuard)) {
if (saved[key] === undefined) {
delete process.env[key]
} else {
process.env[key] = saved[key]
}
}
}
})
it('normalizes an explicit empty TERM and preserves sanitized env deletions on revive', async () => {
await dispatcher.callRequest('pty.spawn', {
env: { TERM: '' },
+49 -11
View File
@@ -24,6 +24,13 @@ import {
scanForShellReady,
type ShellReadyScanState
} from '../main/shell-ready-marker-scanner'
import {
applyTerminalGitCredentialPromptGuard,
clearTerminalGitCredentialPromptGuard,
ensureTerminalGitCredentialPromptGuard,
hasTerminalGitCredentialPromptGuardOwnership
} from '../shared/terminal-git-credential-guard'
import { mergeGitConfigEnvProtocol } from '../shared/git-credential-prompt-env'
// Why: node-pty is a native addon that may not be installed on the remote.
// Dynamic import keeps the require() lazy so loadPty() returns null gracefully
@@ -70,6 +77,7 @@ type ManagedPty = {
terminalHandle?: string
explicitTerm?: string
envToDelete: string[]
gitCredentialPromptGuarded: boolean
startupCommand?: ManagedStartupCommand
}
@@ -184,6 +192,8 @@ type SerializedPtyEntry = {
terminalHandle?: string
explicitTerm?: string
envToDelete?: string[]
/** Optional for state serialized by relays predating the credential guard. */
gitCredentialPromptGuarded?: boolean
}
function sanitizeEnvToDelete(value: unknown): string[] {
@@ -298,16 +308,18 @@ export class PtyHandler {
ctx: { id: string; paneKey?: string; shell: string; command?: string },
envToDelete: readonly string[] = []
): Record<string, string> {
const baseEnv = {
...process.env,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
TERM_PROGRAM: 'Orca',
TERM_PROGRAM_VERSION:
rendererEnv?.ORCA_APP_VERSION || process.env.ORCA_APP_VERSION || '0.0.0-dev',
FORCE_HYPERLINK: '1',
...rendererEnv
} as Record<string, string>
const baseEnv = mergeGitConfigEnvProtocol(
{
...process.env,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
TERM_PROGRAM: 'Orca',
TERM_PROGRAM_VERSION:
rendererEnv?.ORCA_APP_VERSION || process.env.ORCA_APP_VERSION || '0.0.0-dev',
FORCE_HYPERLINK: '1'
},
rendererEnv
) as Record<string, string>
const augmented: Record<string, string> = {}
for (const augmenter of this.envAugmenters) {
try {
@@ -318,7 +330,7 @@ export class PtyHandler {
)
}
}
const result = { ...baseEnv, ...augmented }
const result = mergeGitConfigEnvProtocol(baseEnv, augmented) as Record<string, string>
// Why: match local/daemon precedence so relay defaults and augmenters
// cannot resurrect attribution or identity values explicitly removed.
for (const key of envToDelete) {
@@ -636,6 +648,15 @@ export class PtyHandler {
const shouldProviderDeliverCommand = commandDelivery === 'provider' && command !== undefined
const spawnEnv = this.buildSpawnEnv(env, { id, paneKey, shell, command }, envToDelete)
const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(spawnEnv, command)
// Why: SSH PTYs bypass main's host-env builder. Apply the policy only
// after the relay merges its authoritative process environment so indexed
// Git config and remote Windows/WSL behavior remain intact.
applyTerminalGitCredentialPromptGuard(spawnEnv, {
launchCommand: launchCommandHint,
suppressUserTerminalPrompt: params.suppressUserTerminalGitCredentialPrompt !== false,
platform: process.platform
})
const gitCredentialPromptGuarded = hasTerminalGitCredentialPromptGuardOwnership(spawnEnv)
const shouldEmitShellReadyMarker =
launchCommandHint !== undefined &&
shouldUseShellReadyStartupDelivery({
@@ -688,6 +709,7 @@ export class PtyHandler {
worktreeId,
...(explicitTerm !== undefined ? { explicitTerm } : {}),
envToDelete,
gitCredentialPromptGuarded,
...(terminalHandle ? { terminalHandle } : {}),
...(shouldProviderDeliverCommand
? {
@@ -990,6 +1012,7 @@ export class PtyHandler {
worktreeId: managed.worktreeId,
...(managed.explicitTerm !== undefined ? { explicitTerm: managed.explicitTerm } : {}),
envToDelete: managed.envToDelete,
gitCredentialPromptGuarded: managed.gitCredentialPromptGuarded,
...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {})
})
}
@@ -1058,6 +1081,20 @@ export class PtyHandler {
},
envToDelete
)
// Why: revive lacks the original launch command, so preserve the policy
// decision made at fresh spawn. Legacy Windows state adopts the new safe
// default because the old format cannot represent an opt-out.
const serializedGitCredentialPromptGuarded =
typeof entry.gitCredentialPromptGuarded === 'boolean'
? entry.gitCredentialPromptGuarded
: undefined
const gitCredentialPromptGuarded =
serializedGitCredentialPromptGuarded ?? process.platform === 'win32'
if (gitCredentialPromptGuarded) {
ensureTerminalGitCredentialPromptGuard(spawnEnv, process.platform)
} else {
clearTerminalGitCredentialPromptGuard(spawnEnv)
}
const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv)
const term = ptyMod.spawn(shell, shellLaunch.args, {
// Why: revive must preserve the same terminal identity as fresh spawn.
@@ -1080,6 +1117,7 @@ export class PtyHandler {
worktreeId: entry.worktreeId,
...(explicitTerm !== undefined ? { explicitTerm } : {}),
envToDelete,
gitCredentialPromptGuarded,
...(entry.terminalHandle ? { terminalHandle: entry.terminalHandle } : {})
})
@@ -4,10 +4,21 @@ import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../../../../shared/types'
import { useAppStore } from '../../store'
import { TerminalAdvancedSection } from './TerminalAdvancedSection'
const i18nMock = vi.hoisted(() => ({
language: 'en',
translations: new Map<string, string>()
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, defaultValue: string) => defaultValue
i18n: {
get language() {
return i18nMock.language
}
},
translate: (key: string, defaultValue: string) => i18nMock.translations.get(key) ?? defaultValue
}))
describe('TerminalAdvancedSection scrollback rows', () => {
@@ -16,6 +27,9 @@ describe('TerminalAdvancedSection scrollback rows', () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
i18nMock.language = 'en'
i18nMock.translations.clear()
useAppStore.setState({ settingsSearchQuery: '' })
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
@@ -104,4 +118,30 @@ describe('TerminalAdvancedSection scrollback rows', () => {
expect(updateSettings).toHaveBeenCalledWith({ terminalScrollbackRows: 12345 })
expect(input.value).toBe('12345')
})
it('keeps the credential setting visible for a localized keyword match', () => {
i18nMock.language = 'es'
i18nMock.translations.set(
'auto.components.settings.terminal.windows.search.27e4a4878d',
'gestor de credenciales'
)
useAppStore.setState({ settingsSearchQuery: 'gestor de credenciales' })
act(() => {
root.render(
<TerminalAdvancedSection
settings={{} as GlobalSettings}
updateSettings={vi.fn()}
scrollbackMode="preset"
setScrollbackMode={vi.fn()}
searchQuery="gestor de credenciales"
showWindowsPowerShellImplementation={false}
showWindowsGitCredentialGuard
isMac={false}
/>
)
})
expect(container.textContent).toContain('Block Git Credential Popups')
})
})
@@ -52,6 +52,7 @@ export function TerminalAdvancedSection({
pwshAvailable,
isMac
}: TerminalAdvancedSectionProps): React.JSX.Element {
const terminalGitCredentialPopupSearchEntries = getTerminalGitCredentialPopupSearchEntry()
const scrollbackRows = normalizeDesktopTerminalScrollbackRows(settings.terminalScrollbackRows)
const [scrollbackRowsDraft, setScrollbackRowsDraft] = useState(String(scrollbackRows))
const [prevScrollbackRows, setPrevScrollbackRows] = useState(scrollbackRows)
@@ -305,18 +306,8 @@ export function TerminalAdvancedSection({
) : null}
{showWindowsGitCredentialGuard &&
matchesSettingsSearch(searchQuery, getTerminalGitCredentialPopupSearchEntry()) ? (
<SearchableSetting
title={translate(
'auto.components.settings.terminal.windows.search.8630676830',
'Block Git Credential Popups'
)}
description={translate(
'auto.components.settings.terminal.windows.search.9d8b09bc09',
'Stop git in terminals from opening Git Credential Manager sign-in windows.'
)}
keywords={['git', 'credential', 'popup', 'oauth', 'github', 'sign in', 'gcm', 'prompt']}
>
matchesSettingsSearch(searchQuery, terminalGitCredentialPopupSearchEntries) ? (
<SearchableSetting {...terminalGitCredentialPopupSearchEntries[0]}>
<SettingsSwitchRow
label={translate(
'auto.components.settings.terminal.windows.search.8630676830',
+8 -8
View File
@@ -6764,7 +6764,7 @@
"tui": "TUI",
"tuiDescription": "Eventos discretos de rueda para apps de terminal de pantalla completa."
},
"3fab0f2239": "Stop git in user terminals from opening Git Credential Manager sign-in windows. Saved credentials still work; git fails fast instead of prompting. Applies to new terminals."
"3fab0f2239": "Evita que git abra ventanas de inicio de sesión de Git Credential Manager en terminales de usuario. Las credenciales guardadas siguen funcionando; git falla de inmediato en lugar de solicitar autenticación. Se aplica a terminales nuevas."
},
"TerminalSettingsPreview": {
"a63953a48a": "Vista previa del tema {{value0}}",
@@ -8500,17 +8500,17 @@
"7c7056940a": "shell",
"713c4a2f92": "Elija el shell predeterminado para los nuevos paneles de terminal en Windows.",
"13715f9d23": "Shell predeterminado",
"8630676830": "Block Git Credential Popups",
"9d8b09bc09": "Stop git in terminals from opening Git Credential Manager sign-in windows.",
"8630676830": "Bloquear ventanas emergentes de credenciales de Git",
"9d8b09bc09": "Evita que git abra ventanas de inicio de sesión de Git Credential Manager en terminales.",
"ba9f11ecc3": "git",
"7c7c74ef60": "credential",
"8eff361700": "popup",
"7c7c74ef60": "credencial",
"8eff361700": "ventana emergente",
"7c82e855b0": "oauth",
"bf215181b5": "github",
"b19a25c6a0": "sign in",
"b19a25c6a0": "iniciar sesión",
"ef5acdb939": "gcm",
"4ae35dbb42": "prompt",
"27e4a4878d": "credential manager"
"4ae35dbb42": "solicitud",
"27e4a4878d": "gestor de credenciales"
}
}
},
+8 -8
View File
@@ -6786,7 +6786,7 @@
"tui": "TUI",
"tuiDescription": "全画面ターミナルアプリ向けの離散的なホイール通知。"
},
"3fab0f2239": "Stop git in user terminals from opening Git Credential Manager sign-in windows. Saved credentials still work; git fails fast instead of prompting. Applies to new terminals."
"3fab0f2239": "ユーザーターミナルで Git Credential Manager のサインインウィンドウが開かないようにします。保存済みの認証情報は引き続き使用でき、プロンプトを表示せずに git が即座に失敗します。新しいターミナルに適用されます。"
},
"TerminalSettingsPreview": {
"a63953a48a": "{{value0}} テーマのプレビュー",
@@ -8522,17 +8522,17 @@
"7c7056940a": "シェル",
"713c4a2f92": "Windows 上の新規 terminal ペインのデフォルト シェルを選択します。",
"13715f9d23": "デフォルトのシェル",
"8630676830": "Block Git Credential Popups",
"9d8b09bc09": "Stop git in terminals from opening Git Credential Manager sign-in windows.",
"8630676830": "Git 認証情報ポップアップをブロック",
"9d8b09bc09": "ターミナルで Git Credential Manager のサインインウィンドウが開かないようにします。",
"ba9f11ecc3": "git",
"7c7c74ef60": "credential",
"8eff361700": "popup",
"7c7c74ef60": "認証情報",
"8eff361700": "ポップアップ",
"7c82e855b0": "oauth",
"bf215181b5": "github",
"b19a25c6a0": "sign in",
"b19a25c6a0": "サインイン",
"ef5acdb939": "gcm",
"4ae35dbb42": "prompt",
"27e4a4878d": "credential manager"
"4ae35dbb42": "プロンプト",
"27e4a4878d": "認証情報マネージャー"
}
}
},
+8 -8
View File
@@ -6749,7 +6749,7 @@
"tui": "TUI",
"tuiDescription": "전체 화면 터미널 앱을 위한 개별 휠 보고."
},
"3fab0f2239": "Stop git in user terminals from opening Git Credential Manager sign-in windows. Saved credentials still work; git fails fast instead of prompting. Applies to new terminals."
"3fab0f2239": "사용자 터미널에서 Git Credential Manager 로그인 창이 열리지 않도록 합니다. 저장된 자격 증명은 계속 작동하며, git은 인증을 요청하는 대신 즉시 실패합니다. 새 터미널에 적용됩니다."
},
"TerminalSettingsPreview": {
"a63953a48a": "{{value0}} 테마 미리보기",
@@ -8485,17 +8485,17 @@
"7c7056940a": "셸",
"713c4a2f92": "Windows의 새 terminal 패널에 대한 기본 셸을 선택합니다.",
"13715f9d23": "기본 쉘",
"8630676830": "Block Git Credential Popups",
"9d8b09bc09": "Stop git in terminals from opening Git Credential Manager sign-in windows.",
"8630676830": "Git 자격 증명 팝업 차단",
"9d8b09bc09": "터미널에서 Git Credential Manager 로그인 창이 열리지 않도록 합니다.",
"ba9f11ecc3": "git",
"7c7c74ef60": "credential",
"8eff361700": "popup",
"7c7c74ef60": "자격 증명",
"8eff361700": "팝업",
"7c82e855b0": "oauth",
"bf215181b5": "github",
"b19a25c6a0": "sign in",
"b19a25c6a0": "로그인",
"ef5acdb939": "gcm",
"4ae35dbb42": "prompt",
"27e4a4878d": "credential manager"
"4ae35dbb42": "프롬프트",
"27e4a4878d": "자격 증명 관리자"
}
}
},
+8 -8
View File
@@ -6749,7 +6749,7 @@
"tui": "TUI",
"tuiDescription": "面向全屏终端应用的离散滚轮报告。"
},
"3fab0f2239": "Stop git in user terminals from opening Git Credential Manager sign-in windows. Saved credentials still work; git fails fast instead of prompting. Applies to new terminals."
"3fab0f2239": "阻止用户终端中的 git 打开 Git Credential Manager 登录窗口。已保存的凭据仍可使用;git 会快速失败,而不是提示登录。仅应用于新终端。"
},
"TerminalSettingsPreview": {
"a63953a48a": "预览 {{value0}} 主题",
@@ -8485,17 +8485,17 @@
"7c7056940a": "shell",
"713c4a2f92": "为 Windows 上的新终端窗格选择默认 Shell。",
"13715f9d23": "默认 Shell",
"8630676830": "Block Git Credential Popups",
"9d8b09bc09": "Stop git in terminals from opening Git Credential Manager sign-in windows.",
"8630676830": "阻止 Git 凭据弹窗",
"9d8b09bc09": "阻止终端中的 git 打开 Git Credential Manager 登录窗口。",
"ba9f11ecc3": "git",
"7c7c74ef60": "credential",
"8eff361700": "popup",
"7c7c74ef60": "凭据",
"8eff361700": "弹窗",
"7c82e855b0": "oauth",
"bf215181b5": "github",
"b19a25c6a0": "sign in",
"b19a25c6a0": "登录",
"ef5acdb939": "gcm",
"4ae35dbb42": "prompt",
"27e4a4878d": "credential manager"
"4ae35dbb42": "提示",
"27e4a4878d": "凭据管理器"
}
}
},
+115
View File
@@ -0,0 +1,115 @@
import { addWslEnvKeys } from './wsl-env'
const GIT_CONFIG_WSLENV_KEY_RE = /^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)$/
const GIT_CONFIG_INDEXED_KEY_RE = /^GIT_CONFIG_(?:KEY|VALUE)_(\d+)$/
/** Merge an indexed-config protocol as one atomic environment value. */
export function mergeGitConfigEnvProtocol(
baseEnv: NodeJS.ProcessEnv,
overrideEnv: NodeJS.ProcessEnv | undefined
): NodeJS.ProcessEnv {
const next = { ...baseEnv, ...overrideEnv }
if (!overrideEnv || !Object.keys(overrideEnv).some((key) => GIT_CONFIG_WSLENV_KEY_RE.test(key))) {
return next
}
// Why: COUNT and its indexed pairs form one protocol; retaining lower-priority
// indices behind a smaller override count makes an otherwise valid env ambiguous.
for (const key of Object.keys(next)) {
if (GIT_CONFIG_WSLENV_KEY_RE.test(key)) {
delete next[key]
}
}
for (const [key, value] of Object.entries(overrideEnv)) {
if (GIT_CONFIG_WSLENV_KEY_RE.test(key)) {
next[key] = value
}
}
return next
}
/** Return the safe append position for Git's indexed-config environment protocol. */
export function readValidGitConfigEnvCount(env: NodeJS.ProcessEnv): number | null {
const rawCount = env.GIT_CONFIG_COUNT
const indexedKeys = Object.keys(env).filter((key) => GIT_CONFIG_INDEXED_KEY_RE.test(key))
if (rawCount === undefined) {
return indexedKeys.length === 0 ? 0 : null
}
if (!/^(?:0|[1-9]\d*)$/.test(rawCount)) {
return null
}
const count = Number(rawCount)
if (!Number.isSafeInteger(count) || indexedKeys.length !== count * 2) {
return null
}
for (let index = 0; index < count; index++) {
if (
typeof env[`GIT_CONFIG_KEY_${index}`] !== 'string' ||
typeof env[`GIT_CONFIG_VALUE_${index}`] !== 'string'
) {
return null
}
}
const hasDanglingIndex = indexedKeys.some((key) => {
const match = key.match(GIT_CONFIG_INDEXED_KEY_RE)
return !match || String(Number(match[1])) !== match[1] || Number(match[1]) >= count
})
return hasDanglingIndex ? null : count
}
/** Compose indexed Git config without clobbering caller-provided entries. */
export function appendGitConfigEnv(
env: NodeJS.ProcessEnv,
entries: readonly (readonly [key: string, value: string])[]
): NodeJS.ProcessEnv {
const next = { ...env }
const base = readValidGitConfigEnvCount(env)
if (base === null) {
// Why: ambiguous protocol state may contain caller data at any index, so
// scalar guards are safer than overwriting it with Orca-owned entries.
return next
}
entries.forEach(([key, value], index) => {
next[`GIT_CONFIG_KEY_${base + index}`] = key
next[`GIT_CONFIG_VALUE_${base + index}`] = value
})
next.GIT_CONFIG_COUNT = String(base + entries.length)
return next
}
/**
* Disable interactive Git credential UI while preserving cached credentials
* and caller-provided askpass programs.
*/
export function gitCredentialPromptGuardEnv(
env: NodeJS.ProcessEnv,
platform: NodeJS.Platform = process.platform
): NodeJS.ProcessEnv {
const next = appendGitConfigEnv(
{
...env,
GIT_TERMINAL_PROMPT: '0',
GIT_ASKPASS: env.GIT_ASKPASS ?? '',
SSH_ASKPASS: env.SSH_ASKPASS ?? '',
// Why: GCM can ignore terminal/askpass guards and open its own GUI.
GCM_INTERACTIVE: 'never'
},
// Why: keep the helper so cached credentials continue to work; disable
// only its interactive fallback.
[
['credential.interactive', 'false'],
['credential.guiPrompt', 'false']
]
)
if (platform === 'win32') {
// Why: wsl.exe imports only variables registered in WSLENV. Indexed Git
// config must cross as a complete set or Git rejects the count.
const configKeys =
readValidGitConfigEnvCount(next) === null
? []
: Object.keys(next).filter((key) => GIT_CONFIG_WSLENV_KEY_RE.test(key))
addWslEnvKeys(next, ['GIT_TERMINAL_PROMPT', 'GCM_INTERACTIVE', ...configKeys])
}
return next
}
@@ -0,0 +1,61 @@
export const TERMINAL_GIT_CREDENTIAL_GUARD_STATE_ENV = 'ORCA_TERMINAL_GIT_CREDENTIAL_GUARD_STATE'
export type GuardedScalarKey =
| 'GIT_TERMINAL_PROMPT'
| 'GCM_INTERACTIVE'
| 'GIT_ASKPASS'
| 'SSH_ASKPASS'
export type TerminalGitCredentialGuardState = {
version: 1
previous: Record<GuardedScalarKey, string | null> & { WSLENV: string | null }
previousState: string | null
previousGitConfigCount: string | null
guardedWslEnv: string | null
configStart: number | null
}
export function readOwnEnvValue(env: Record<string, string>, key: string): string | null {
return Object.prototype.hasOwnProperty.call(env, key) ? env[key] : null
}
export function parseTerminalGitCredentialGuardState(
value: string | undefined
): TerminalGitCredentialGuardState | null {
if (!value) {
return null
}
try {
const parsed = JSON.parse(value) as Partial<TerminalGitCredentialGuardState>
const previous = parsed.previous as Record<string, unknown> | undefined
const hasValidPreviousValues = [
'GIT_TERMINAL_PROMPT',
'GCM_INTERACTIVE',
'GIT_ASKPASS',
'SSH_ASKPASS',
'WSLENV'
].every((key) => previous?.[key] === null || typeof previous?.[key] === 'string')
if (
parsed.version !== 1 ||
!previous ||
!hasValidPreviousValues ||
(parsed.previousState !== null && typeof parsed.previousState !== 'string') ||
(parsed.previousGitConfigCount !== null &&
typeof parsed.previousGitConfigCount !== 'string') ||
(parsed.guardedWslEnv !== null && typeof parsed.guardedWslEnv !== 'string') ||
(parsed.configStart !== null &&
(typeof parsed.configStart !== 'number' ||
!Number.isInteger(parsed.configStart) ||
parsed.configStart < 0))
) {
return null
}
return parsed as TerminalGitCredentialGuardState
} catch {
return null
}
}
export function hasTerminalGitCredentialPromptGuardOwnership(env: Record<string, string>): boolean {
return parseTerminalGitCredentialGuardState(env[TERMINAL_GIT_CREDENTIAL_GUARD_STATE_ENV]) !== null
}
@@ -0,0 +1,96 @@
import {
readOwnEnvValue,
type TerminalGitCredentialGuardState
} from './terminal-git-credential-guard-state'
const WSLENV_CONFIG_INDEX_RE = /^(GIT_CONFIG_(?:KEY|VALUE)_)(\d+)(\/.*)?$/
const OWNED_CONFIG_ENTRY_COUNT = 2
function wslenvTokenName(token: string): string {
return token.split('/')[0]
}
function countTokens(tokens: readonly string[]): Map<string, number> {
const counts = new Map<string, number>()
for (const token of tokens) {
counts.set(token, (counts.get(token) ?? 0) + 1)
}
return counts
}
export function restoreTerminalGitCredentialGuardWslEnv(
env: Record<string, string>,
state: TerminalGitCredentialGuardState,
configIndexMap: Map<number, number> | null
): void {
const current = readOwnEnvValue(env, 'WSLENV')
const configStart = state.configStart
const hasPostGuardCallerEntry =
configStart !== null &&
configIndexMap !== null &&
[...configIndexMap.keys()].some((index) => index >= configStart)
const retainedGuardIndexes = new Set(
configStart === null || !configIndexMap
? []
: [...configIndexMap.keys()].filter(
(index) => index >= configStart && index < configStart + OWNED_CONFIG_ENTRY_COUNT
)
)
if (current === state.guardedWslEnv && retainedGuardIndexes.size === 0) {
if (state.previous.WSLENV === null) {
delete env.WSLENV
} else {
env.WSLENV = state.previous.WSLENV
}
return
}
if (current === null || state.guardedWslEnv === null) {
return
}
const previousTokenCounts = countTokens((state.previous.WSLENV ?? '').split(':').filter(Boolean))
const ownedTokenCounts = countTokens(state.guardedWslEnv.split(':').filter(Boolean))
for (const [token, count] of previousTokenCounts) {
ownedTokenCounts.set(token, Math.max(0, (ownedTokenCounts.get(token) ?? 0) - count))
}
const tokens = current.split(':').flatMap((token) => {
const match = token.match(WSLENV_CONFIG_INDEX_RE)
const oldIndex = match ? Number(match[2]) : null
const mappedIndex = oldIndex === null ? undefined : configIndexMap?.get(oldIndex)
const transfersOwnedConfigToken =
mappedIndex !== undefined &&
(hasPostGuardCallerEntry || (oldIndex !== null && retainedGuardIndexes.has(oldIndex)))
const ownedCount = ownedTokenCounts.get(token) ?? 0
if (ownedCount > 0 && !transfersOwnedConfigToken) {
ownedTokenCounts.set(token, ownedCount - 1)
return []
}
if (match && configIndexMap) {
if (mappedIndex === undefined) {
return [token]
}
return [`${match[1]}${mappedIndex}${match[3] ?? ''}`]
}
return [token]
})
const tokenNames = new Set(tokens.map(wslenvTokenName))
const count = Number(env.GIT_CONFIG_COUNT ?? '0')
const hasCompleteForwarding =
Number.isSafeInteger(count) &&
count > 0 &&
Array.from(
{ length: count },
(_, index) =>
tokenNames.has(`GIT_CONFIG_KEY_${index}`) && tokenNames.has(`GIT_CONFIG_VALUE_${index}`)
).every(Boolean)
// Why: caller entries created after ownership began need a complete WSL
// protocol, while untouched pre-guard config must return to its original state.
if (hasPostGuardCallerEntry && hasCompleteForwarding && !tokenNames.has('GIT_CONFIG_COUNT')) {
tokens.push('GIT_CONFIG_COUNT')
}
if (tokens.length === 0 && state.previous.WSLENV === null) {
delete env.WSLENV
} else {
env.WSLENV = tokens.join(':')
}
}
+290
View File
@@ -0,0 +1,290 @@
import { recognizeAgentProcessFromCommandLine } from './agent-process-recognition'
import {
gitCredentialPromptGuardEnv,
readValidGitConfigEnvCount
} from './git-credential-prompt-env'
import {
type GuardedScalarKey,
parseTerminalGitCredentialGuardState,
readOwnEnvValue,
TERMINAL_GIT_CREDENTIAL_GUARD_STATE_ENV,
type TerminalGitCredentialGuardState
} from './terminal-git-credential-guard-state'
import { restoreTerminalGitCredentialGuardWslEnv } from './terminal-git-credential-guard-wslenv'
import { addWslEnvKeys } from './wsl-env'
export {
hasTerminalGitCredentialPromptGuardOwnership,
TERMINAL_GIT_CREDENTIAL_GUARD_STATE_ENV
} from './terminal-git-credential-guard-state'
const GIT_CONFIG_PROTOCOL_KEY_RE = /^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)$/
const GUARD_CONFIG_ENTRIES = [
['credential.interactive', 'false'],
['credential.guiPrompt', 'false']
] as const
export const TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV =
'ORCA_INTERNAL_TERMINAL_GIT_CREDENTIAL_GUARD_POLICY'
function hasWslEnvKeys(env: Record<string, string>, keys: readonly string[]): boolean {
const names = new Set((env.WSLENV ?? '').split(':').filter(Boolean).map(wslenvTokenName))
return keys.every((key) => names.has(key))
}
function hasEffectiveGuardConfigEntries(env: Record<string, string>, count: number): boolean {
const effective = new Map<string, string>()
for (let index = 0; index < count; index++) {
effective.set(env[`GIT_CONFIG_KEY_${index}`], env[`GIT_CONFIG_VALUE_${index}`])
}
return GUARD_CONFIG_ENTRIES.every(([key, value]) => effective.get(key) === value)
}
function isGuardIntact(
env: Record<string, string>,
state: TerminalGitCredentialGuardState,
platform: NodeJS.Platform
): boolean {
if (
env.GIT_TERMINAL_PROMPT !== '0' ||
env.GCM_INTERACTIVE !== 'never' ||
typeof env.GIT_ASKPASS !== 'string' ||
typeof env.SSH_ASKPASS !== 'string'
) {
return false
}
const requiredWslKeys = [
TERMINAL_GIT_CREDENTIAL_GUARD_STATE_ENV,
'GIT_TERMINAL_PROMPT',
'GCM_INTERACTIVE'
]
const count = readValidGitConfigEnvCount(env)
// Why: scalar-only ownership is a fallback for ambiguous caller config;
// once that protocol becomes valid, upgrade it to the full indexed guard.
if (state.configStart === null && count !== null) {
return false
}
if (state.configStart !== null) {
if (count === null || count < state.configStart + GUARD_CONFIG_ENTRIES.length) {
return false
}
for (const [offset, [key, value]] of GUARD_CONFIG_ENTRIES.entries()) {
const index = state.configStart + offset
if (env[`GIT_CONFIG_KEY_${index}`] !== key || env[`GIT_CONFIG_VALUE_${index}`] !== value) {
return false
}
}
if (!hasEffectiveGuardConfigEntries(env, count)) {
return false
}
requiredWslKeys.push(...Object.keys(env).filter((key) => GIT_CONFIG_PROTOCOL_KEY_RE.test(key)))
}
return platform !== 'win32' || hasWslEnvKeys(env, requiredWslKeys)
}
/** Apply the guard once and record only the environment state Orca owns. */
export function ensureTerminalGitCredentialPromptGuard(
env: Record<string, string>,
platform: NodeJS.Platform = process.platform
): void {
const existingState = parseTerminalGitCredentialGuardState(
env[TERMINAL_GIT_CREDENTIAL_GUARD_STATE_ENV]
)
if (existingState) {
if (isGuardIntact(env, existingState, platform)) {
return
}
// Why: ownership alone is not proof that inherited shells still carry the
// safety guard; release it before recording a fresh, verifiable baseline.
clearTerminalGitCredentialPromptGuard(env)
}
const previousState = readOwnEnvValue(env, TERMINAL_GIT_CREDENTIAL_GUARD_STATE_ENV)
const previous = {
GIT_TERMINAL_PROMPT: readOwnEnvValue(env, 'GIT_TERMINAL_PROMPT'),
GCM_INTERACTIVE: readOwnEnvValue(env, 'GCM_INTERACTIVE'),
GIT_ASKPASS: readOwnEnvValue(env, 'GIT_ASKPASS'),
SSH_ASKPASS: readOwnEnvValue(env, 'SSH_ASKPASS'),
WSLENV: readOwnEnvValue(env, 'WSLENV')
}
const configStart = readValidGitConfigEnvCount(env)
const guarded = gitCredentialPromptGuardEnv(env, platform) as Record<string, string>
if (platform === 'win32') {
// Why: nested Orca instances inside WSL need the ownership marker so an
// opted-out user terminal can restore only the guard Orca injected.
addWslEnvKeys(guarded, [TERMINAL_GIT_CREDENTIAL_GUARD_STATE_ENV])
}
const state: TerminalGitCredentialGuardState = {
version: 1,
previous,
previousState,
previousGitConfigCount: readOwnEnvValue(env, 'GIT_CONFIG_COUNT'),
guardedWslEnv: readOwnEnvValue(guarded, 'WSLENV'),
configStart
}
guarded[TERMINAL_GIT_CREDENTIAL_GUARD_STATE_ENV] = JSON.stringify(state)
Object.assign(env, guarded)
}
function restoreScalarIfUnchanged(
env: Record<string, string>,
key: GuardedScalarKey,
guardedValue: string,
previousValue: string | null
): void {
if (env[key] !== guardedValue) {
return
}
if (previousValue === null) {
delete env[key]
} else {
env[key] = previousValue
}
}
function removeOwnedGitConfigEntries(
env: Record<string, string>,
state: TerminalGitCredentialGuardState
): Map<number, number> | null {
const start = state.configStart
if (start === null) {
return null
}
const count = readValidGitConfigEnvCount(env)
if (count === null || count < start + GUARD_CONFIG_ENTRIES.length) {
return null
}
const removableIndexes = new Set<number>()
for (const [offset, [expectedKey, expectedValue]] of GUARD_CONFIG_ENTRIES.entries()) {
const index = start + offset
if (
env[`GIT_CONFIG_KEY_${index}`] === expectedKey &&
env[`GIT_CONFIG_VALUE_${index}`] === expectedValue
) {
removableIndexes.add(index)
}
}
if (removableIndexes.size === 0) {
return null
}
const kept: { oldIndex: number; key: string; value: string }[] = []
for (let index = 0; index < count; index++) {
const key = env[`GIT_CONFIG_KEY_${index}`]
const value = env[`GIT_CONFIG_VALUE_${index}`]
if (key === undefined || value === undefined) {
return null
}
if (!removableIndexes.has(index)) {
kept.push({ oldIndex: index, key, value })
}
}
const indexMap = new Map<number, number>()
kept.forEach((entry, newIndex) => {
indexMap.set(entry.oldIndex, newIndex)
env[`GIT_CONFIG_KEY_${newIndex}`] = entry.key
env[`GIT_CONFIG_VALUE_${newIndex}`] = entry.value
})
for (let index = kept.length; index < count; index++) {
delete env[`GIT_CONFIG_KEY_${index}`]
delete env[`GIT_CONFIG_VALUE_${index}`]
}
const removedEntireGuard = removableIndexes.size === GUARD_CONFIG_ENTRIES.length
const hasEntriesAfterGuard = count > start + GUARD_CONFIG_ENTRIES.length
if (removedEntireGuard && !hasEntriesAfterGuard && state.previousGitConfigCount !== null) {
env.GIT_CONFIG_COUNT = state.previousGitConfigCount
} else if (kept.length === 0) {
delete env.GIT_CONFIG_COUNT
} else {
env.GIT_CONFIG_COUNT = String(kept.length)
}
return indexMap
}
function wslenvTokenName(token: string): string {
return token.split('/')[0]
}
/** Remove only a guard carrying Orca's valid ownership record. */
export function clearTerminalGitCredentialPromptGuard(env: Record<string, string>): void {
const state = parseTerminalGitCredentialGuardState(env[TERMINAL_GIT_CREDENTIAL_GUARD_STATE_ENV])
if (!state) {
return
}
restoreScalarIfUnchanged(env, 'GIT_TERMINAL_PROMPT', '0', state.previous.GIT_TERMINAL_PROMPT)
restoreScalarIfUnchanged(env, 'GCM_INTERACTIVE', 'never', state.previous.GCM_INTERACTIVE)
restoreScalarIfUnchanged(
env,
'GIT_ASKPASS',
state.previous.GIT_ASKPASS ?? '',
state.previous.GIT_ASKPASS
)
restoreScalarIfUnchanged(
env,
'SSH_ASKPASS',
state.previous.SSH_ASKPASS ?? '',
state.previous.SSH_ASKPASS
)
const configIndexMap = removeOwnedGitConfigEntries(env, state)
restoreTerminalGitCredentialGuardWslEnv(env, state, configIndexMap)
if (state.previousState === null) {
delete env[TERMINAL_GIT_CREDENTIAL_GUARD_STATE_ENV]
} else {
env[TERMINAL_GIT_CREDENTIAL_GUARD_STATE_ENV] = state.previousState
}
}
/**
* Disable Git credential UI for unattended agent terminals and, by default,
* user terminals hosted on Windows.
*/
export function applyTerminalGitCredentialPromptGuard(
env: Record<string, string>,
opts: {
launchCommand?: string | null
suppressUserTerminalPrompt: boolean
platform?: NodeJS.Platform
/** A detached host will apply or clear the policy after its final merge. */
deferGitConfigGuardToHost?: boolean
}
): void {
const isAgentTerminal = Boolean(
recognizeAgentProcessFromCommandLine(opts.launchCommand, { includeHeadlessOneShot: true })
)
const platform = opts.platform ?? process.platform
const shouldGuard = isAgentTerminal || (opts.suppressUserTerminalPrompt && platform === 'win32')
if (!shouldGuard) {
clearTerminalGitCredentialPromptGuard(env)
if (opts.deferGitConfigGuardToHost) {
env[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV] = 'clear'
}
return
}
if (!opts.deferGitConfigGuardToHost) {
ensureTerminalGitCredentialPromptGuard(env, platform)
return
}
env[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV] = 'guard'
const hadWslEnv = 'WSLENV' in env
for (const [key, value] of Object.entries(gitCredentialPromptGuardEnv(env, platform))) {
if (typeof value !== 'string') {
continue
}
if (GIT_CONFIG_PROTOCOL_KEY_RE.test(key)) {
continue
}
if (key === 'WSLENV' && !hadWslEnv) {
continue
}
if ((key === 'GIT_ASKPASS' || key === 'SSH_ASKPASS') && !(key in env)) {
continue
}
env[key] = value
}
}
+17
View File
@@ -0,0 +1,17 @@
export function addWslEnvKeys(
env: Record<string, string | undefined>,
keys: readonly string[]
): void {
const existing = env.WSLENV ?? process.env.WSLENV ?? ''
const tokens = existing.split(':').filter(Boolean)
const tokenNames = new Set(tokens.map((token) => token.split('/')[0]))
for (const key of keys) {
if (!tokenNames.has(key)) {
tokens.push(key)
tokenNames.add(key)
}
}
env.WSLENV = tokens.join(':')
}