From dad513d6d62df1624cb00688bae2d7cdd4c612a4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:56:24 -0700 Subject: [PATCH] fix(terminal): harden Git credential popup guard --- src/main/daemon/pty-subprocess.test.ts | 169 ++++++++ src/main/daemon/pty-subprocess.ts | 32 +- src/main/daemon/types.ts | 4 +- src/main/git/runner.test.ts | 42 ++ src/main/git/runner.ts | 67 +-- src/main/ipc/pty.test.ts | 28 ++ src/main/ipc/pty.ts | 12 +- .../ipc/terminal-git-credential-guard.test.ts | 406 +++++++++++++++++- src/main/ipc/terminal-git-credential-guard.ts | 59 +-- src/main/providers/local-pty-provider.test.ts | 42 ++ src/main/providers/local-pty-provider.ts | 4 +- src/main/providers/ssh-pty-provider.test.ts | 15 + src/main/providers/ssh-pty-provider.ts | 5 + src/main/providers/types.ts | 3 + src/main/wsl-env.ts | 18 +- src/relay/pty-handler.test.ts | 162 +++++++ src/relay/pty-handler.ts | 60 ++- .../settings/TerminalAdvancedSection.test.tsx | 42 +- .../settings/TerminalAdvancedSection.tsx | 15 +- src/renderer/src/i18n/locales/es.json | 16 +- src/renderer/src/i18n/locales/ja.json | 16 +- src/renderer/src/i18n/locales/ko.json | 16 +- src/renderer/src/i18n/locales/zh.json | 16 +- src/shared/git-credential-prompt-env.ts | 115 +++++ .../terminal-git-credential-guard-state.ts | 61 +++ .../terminal-git-credential-guard-wslenv.ts | 96 +++++ src/shared/terminal-git-credential-guard.ts | 290 +++++++++++++ src/shared/wsl-env.ts | 17 + 28 files changed, 1631 insertions(+), 197 deletions(-) create mode 100644 src/shared/git-credential-prompt-env.ts create mode 100644 src/shared/terminal-git-credential-guard-state.ts create mode 100644 src/shared/terminal-git-credential-guard-wslenv.ts create mode 100644 src/shared/terminal-git-credential-guard.ts create mode 100644 src/shared/wsl-env.ts diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 99a35047743..3b03cf846a2 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -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 + 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 + 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 = {} + 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 + 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) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index ce1b1faf35c..a4e71ee99f9 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -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, + explicitEnv: Record | 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 = { - ...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 + composeGuardedDaemonGitConfigEnv(env, opts.env) for (const key of opts.envToDelete ?? []) { delete env[key] } diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 550b211aa74..1643a3e2a80 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -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 ────────────────────────────────────────── diff --git a/src/main/git/runner.test.ts b/src/main/git/runner.test.ts index d0da8e1caac..92832fc0f89 100644 --- a/src/main/git/runner.test.ts +++ b/src/main/git/runner.test.ts @@ -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)', () => { diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index 9ef2e66fc86..faeca7bc282 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -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) } /** diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 35d386eb473..00f08e71b52 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -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() diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 166a308e96d..6374199fd10 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -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 { @@ -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) { diff --git a/src/main/ipc/terminal-git-credential-guard.test.ts b/src/main/ipc/terminal-git-credential-guard.test.ts index f8711434b28..27131c4a832 100644 --- a/src/main/ipc/terminal-git-credential-guard.test.ts +++ b/src/main/ipc/terminal-git-credential-guard.test.ts @@ -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): 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 = { + 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 = { + 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { + 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 = { + 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 = { 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 = { + 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 = { 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 = { + 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 = { 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', () => { diff --git a/src/main/ipc/terminal-git-credential-guard.ts b/src/main/ipc/terminal-git-credential-guard.ts index 7f1edc66067..5723fcfa707 100644 --- a/src/main/ipc/terminal-git-credential-guard.ts +++ b/src/main/ipc/terminal-git-credential-guard.ts @@ -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, - 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' diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 66f68d6e868..cdb7656b236 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -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 + 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, diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 90ccae11ea6..9c69bbd6ae9 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -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 = { - ...process.env, - ...args.env, + ...mergeGitConfigEnvProtocol(process.env, args.env), TERM: 'xterm-256color', COLORTERM: 'truecolor', TERM_PROGRAM: 'Orca', diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index ca0a9fd8261..d1d408f1791 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -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' }) diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index e386e89dbba..64cefe72474 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -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. diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index d5cf5c60985..c8d8542c353 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -60,6 +60,9 @@ export type PtySpawnOptions = { cwd?: string env?: Record 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 diff --git a/src/main/wsl-env.ts b/src/main/wsl-env.ts index 71e836e945b..c77d15ebf17 100644 --- a/src/main/wsl-env.ts +++ b/src/main/wsl-env.ts @@ -1,17 +1 @@ -export function addWslEnvKeys( - env: Record, - 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' diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index 332828408bd..bc83c3fe088 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -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 + 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 + 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 + 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 + 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 = {} + 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 + 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: '' }, diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 965d8cbdc30..271cff6d499 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -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 { - 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 + 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 const augmented: Record = {} 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 // 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 } : {}) }) diff --git a/src/renderer/src/components/settings/TerminalAdvancedSection.test.tsx b/src/renderer/src/components/settings/TerminalAdvancedSection.test.tsx index c775df0a9aa..425bdefbca5 100644 --- a/src/renderer/src/components/settings/TerminalAdvancedSection.test.tsx +++ b/src/renderer/src/components/settings/TerminalAdvancedSection.test.tsx @@ -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() +})) + 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( + + ) + }) + + expect(container.textContent).toContain('Block Git Credential Popups') + }) }) diff --git a/src/renderer/src/components/settings/TerminalAdvancedSection.tsx b/src/renderer/src/components/settings/TerminalAdvancedSection.tsx index fb89e440863..e624250092c 100644 --- a/src/renderer/src/components/settings/TerminalAdvancedSection.tsx +++ b/src/renderer/src/components/settings/TerminalAdvancedSection.tsx @@ -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()) ? ( - + matchesSettingsSearch(searchQuery, terminalGitCredentialPopupSearchEntries) ? ( + 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 +} diff --git a/src/shared/terminal-git-credential-guard-state.ts b/src/shared/terminal-git-credential-guard-state.ts new file mode 100644 index 00000000000..aee729cd508 --- /dev/null +++ b/src/shared/terminal-git-credential-guard-state.ts @@ -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 & { WSLENV: string | null } + previousState: string | null + previousGitConfigCount: string | null + guardedWslEnv: string | null + configStart: number | null +} + +export function readOwnEnvValue(env: Record, 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 + const previous = parsed.previous as Record | 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): boolean { + return parseTerminalGitCredentialGuardState(env[TERMINAL_GIT_CREDENTIAL_GUARD_STATE_ENV]) !== null +} diff --git a/src/shared/terminal-git-credential-guard-wslenv.ts b/src/shared/terminal-git-credential-guard-wslenv.ts new file mode 100644 index 00000000000..d8535dda657 --- /dev/null +++ b/src/shared/terminal-git-credential-guard-wslenv.ts @@ -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 { + const counts = new Map() + for (const token of tokens) { + counts.set(token, (counts.get(token) ?? 0) + 1) + } + return counts +} + +export function restoreTerminalGitCredentialGuardWslEnv( + env: Record, + state: TerminalGitCredentialGuardState, + configIndexMap: Map | 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(':') + } +} diff --git a/src/shared/terminal-git-credential-guard.ts b/src/shared/terminal-git-credential-guard.ts new file mode 100644 index 00000000000..acfd780a18b --- /dev/null +++ b/src/shared/terminal-git-credential-guard.ts @@ -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, 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, count: number): boolean { + const effective = new Map() + 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, + 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, + 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 + 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, + 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, + state: TerminalGitCredentialGuardState +): Map | 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() + 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() + 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): 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, + 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 + } +} diff --git a/src/shared/wsl-env.ts b/src/shared/wsl-env.ts new file mode 100644 index 00000000000..71e836e945b --- /dev/null +++ b/src/shared/wsl-env.ts @@ -0,0 +1,17 @@ +export function addWslEnvKeys( + env: Record, + 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(':') +}