mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 16:02:55 +00:00
fix(terminal): harden Git credential popup guard
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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 ──────────────────────────────────────────
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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": "認証情報マネージャー"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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": "자격 증명 관리자"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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": "凭据管理器"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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(':')
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(':')
|
||||
}
|
||||
Reference in New Issue
Block a user