mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 00:02:37 +00:00
* Suppress Git Credential Manager OAuth popup on git clone (fixes #7652)
Orca's git runner disables the interactive credential prompt on every git
call that goes through gitExecFileAsync/gitStreamStdout, but the two raw
'git clone' spawns (desktop repos:clone and the runtime clone path) passed
no env, so they inherited process.env with no guard. On Windows a clone
that needs GitHub auth then makes Git Credential Manager pop its
'Connect to GitHub' OAuth window, and in a network-restricted intranet the
browser/device flow never completes while git's credential retry re-pops it.
Apply nonInteractiveGitEnv() to both clone spawns so the prompt is
suppressed (GCM_INTERACTIVE=never, credential.interactive=false,
GIT_TERMINAL_PROMPT=0). The credential *helper* is kept, so cached-token
clones for private repos still work; only the interactive fallback popup is
disabled and the clone fails fast with a clear error instead.
* Suppress GCM OAuth popup in agent terminals and setup hooks too (#7652)
The clone-spawn fix stopped Orca's own managed git from popping Git
Credential Manager, but git run in terminals and setup scripts inherited
process.env with no guard. That is the more likely source of the reported
loop: agents are told to run 'git pull --rebase'/'git fetch'/retry 'git
push' (preamble + conflict/push-failure prompts), and each retry re-pops
GCM's 'Connect to GitHub' window in a network-restricted intranet.
Apply the credential-prompt guard to:
- setup/archive/hook scripts (hooks.ts non-WSL exec env), which run
unattended on worktree create/archive.
- the shared PTY host env (buildPtyHostEnv), via a small
applyTerminalGitCredentialPromptGuard helper. Agent terminals are
guarded unconditionally (they cannot dismiss a GUI popup); user
terminals are guarded by default via the new
terminalSuppressGitCredentialPrompt setting so power users can opt out.
The credential helper is kept, so cached gh auth still works; only the
interactive fallback prompt is disabled. Verified end-to-end in a real
Orca terminal (GIT_TERMINAL_PROMPT=0 + GCM_INTERACTIVE=never by default;
absent when the opt-out is set).
* Scope user-terminal credential guard to Windows, add settings toggle, forward guard into WSL (#7652)
* Retrigger PR checks (Actions dropped the synchronize dispatch for 57e7ce249)
* Keep shell locale out of the terminal/hook credential guard (#7652 review fix)
* Fix Fable review findings: guard WSL hook branch, wire settings search, catalog keyword keys, sparse-env askpass, one-shot agent classification (#7652)
* fix(terminal): harden Git credential popup guard
* test(pty): cover SSH credential guard setting
* fix(git): guard remote clones and setup runners
* fix(git): scope credential guards to unattended work
---------
Co-authored-by: Brennan Benson <brennanbenson@Brennans-MacBook-Pro.local>
278 lines
9.4 KiB
TypeScript
278 lines
9.4 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import { homedir } from 'node:os'
|
|
import {
|
|
buildRelayCommandEnv,
|
|
buildRelayGitEnv,
|
|
buildRelayUnattendedGitEnv
|
|
} from './relay-command-env'
|
|
|
|
// homedir() is the fallback when the relay env carries no HOME; mock it so the
|
|
// fallback path is deterministic and the "no resolvable home" branch is reachable.
|
|
vi.mock('os', () => ({ homedir: vi.fn(() => '/home/fallback') }))
|
|
|
|
describe('buildRelayCommandEnv', () => {
|
|
it('adds POSIX git locations when the relay starts with an empty PATH', () => {
|
|
const env = buildRelayCommandEnv({ HOME: '/home/me', PATH: '' }, 'linux')
|
|
|
|
expect(env.PATH?.split(':')).toEqual(
|
|
expect.arrayContaining(['/usr/local/bin', '/usr/bin', '/bin'])
|
|
)
|
|
expect(env.HOME).toBe('/home/me')
|
|
})
|
|
|
|
it('preserves Windows Path casing and adds Git install locations', () => {
|
|
const env = buildRelayCommandEnv({ Path: 'C:\\Tools' }, 'win32')
|
|
|
|
expect(env.PATH).toBeUndefined()
|
|
expect(env.Path?.split(';')).toEqual(
|
|
expect.arrayContaining(['C:\\Tools', 'C:\\Program Files\\Git\\cmd'])
|
|
)
|
|
})
|
|
|
|
it('adds per-user package-manager bins resolved from HOME on POSIX', () => {
|
|
const env = buildRelayCommandEnv({ HOME: '/home/me', PATH: '/usr/bin' }, 'linux')
|
|
|
|
expect(env.PATH?.split(':')).toEqual(
|
|
expect.arrayContaining([
|
|
'/home/me/.local/bin',
|
|
'/home/me/.npm-global/bin',
|
|
'/home/me/.cargo/bin',
|
|
'/home/me/.bun/bin',
|
|
'/home/me/go/bin',
|
|
'/home/me/.deno/bin',
|
|
'/home/me/.local/share/pnpm'
|
|
])
|
|
)
|
|
})
|
|
|
|
it('honors a relocated npm global prefix via npm_config_prefix', () => {
|
|
const env = buildRelayCommandEnv(
|
|
{ HOME: '/home/me', PATH: '', npm_config_prefix: '/opt/npm' },
|
|
'linux'
|
|
)
|
|
|
|
expect(env.PATH?.split(':')).toContain('/opt/npm/bin')
|
|
})
|
|
|
|
it('honors a relocated cargo home via CARGO_HOME', () => {
|
|
const env = buildRelayCommandEnv(
|
|
{ HOME: '/home/me', PATH: '', CARGO_HOME: '/opt/cargo' },
|
|
'linux'
|
|
)
|
|
const segments = env.PATH?.split(':') ?? []
|
|
|
|
expect(segments).toContain('/opt/cargo/bin')
|
|
expect(segments).not.toContain('/home/me/.cargo/bin')
|
|
})
|
|
|
|
it('honors a relocated bun install via BUN_INSTALL', () => {
|
|
const env = buildRelayCommandEnv(
|
|
{ HOME: '/home/me', PATH: '', BUN_INSTALL: '/opt/bun' },
|
|
'linux'
|
|
)
|
|
const segments = env.PATH?.split(':') ?? []
|
|
|
|
expect(segments).toContain('/opt/bun/bin')
|
|
expect(segments).not.toContain('/home/me/.bun/bin')
|
|
})
|
|
|
|
it('honors a relocated deno install via DENO_INSTALL', () => {
|
|
const env = buildRelayCommandEnv(
|
|
{ HOME: '/home/me', PATH: '', DENO_INSTALL: '/opt/deno' },
|
|
'linux'
|
|
)
|
|
const segments = env.PATH?.split(':') ?? []
|
|
|
|
expect(segments).toContain('/opt/deno/bin')
|
|
expect(segments).not.toContain('/home/me/.deno/bin')
|
|
})
|
|
|
|
it('uses GOBIN directly for the go bin directory', () => {
|
|
const env = buildRelayCommandEnv({ HOME: '/home/me', PATH: '', GOBIN: '/opt/go/bin' }, 'linux')
|
|
const segments = env.PATH?.split(':') ?? []
|
|
|
|
expect(segments).toContain('/opt/go/bin')
|
|
expect(segments).not.toContain('/home/me/go/bin')
|
|
})
|
|
|
|
it('honors GOPATH for the go bin directory when GOBIN is unset', () => {
|
|
const env = buildRelayCommandEnv({ HOME: '/home/me', PATH: '', GOPATH: '/opt/gopath' }, 'linux')
|
|
const segments = env.PATH?.split(':') ?? []
|
|
|
|
expect(segments).toContain('/opt/gopath/bin')
|
|
expect(segments).not.toContain('/home/me/go/bin')
|
|
})
|
|
|
|
it('honors PNPM_HOME for the pnpm global bin directory', () => {
|
|
const env = buildRelayCommandEnv(
|
|
{ HOME: '/home/me', PATH: '', PNPM_HOME: '/opt/pnpm' },
|
|
'linux'
|
|
)
|
|
const segments = env.PATH?.split(':') ?? []
|
|
|
|
expect(segments).toContain('/opt/pnpm')
|
|
expect(segments).not.toContain('/home/me/.local/share/pnpm')
|
|
})
|
|
|
|
it('honors XDG_DATA_HOME for pnpm when PNPM_HOME is unset', () => {
|
|
const env = buildRelayCommandEnv(
|
|
{ HOME: '/home/me', PATH: '', XDG_DATA_HOME: '/opt/xdg' },
|
|
'linux'
|
|
)
|
|
const segments = env.PATH?.split(':') ?? []
|
|
|
|
expect(segments).toContain('/opt/xdg/pnpm')
|
|
expect(segments).not.toContain('/home/me/.local/share/pnpm')
|
|
})
|
|
|
|
it('adds the macOS pnpm home for Darwin relay envs', () => {
|
|
const env = buildRelayCommandEnv({ HOME: '/Users/me', PATH: '' }, 'darwin')
|
|
|
|
expect(env.PATH?.split(':')).toContain('/Users/me/Library/pnpm')
|
|
})
|
|
|
|
it('does not leak POSIX user bins into a Windows relay env', () => {
|
|
const env = buildRelayCommandEnv({ Path: 'C:\\Tools', HOME: '/home/me' }, 'win32')
|
|
|
|
expect(env.Path).not.toContain('/home/me/.local/bin')
|
|
expect(env.Path).not.toContain('.npm-global')
|
|
})
|
|
|
|
it('adds Windows user package-manager bins to a Windows relay env', () => {
|
|
const env = buildRelayCommandEnv(
|
|
{
|
|
Path: 'C:\\Tools',
|
|
APPDATA: 'C:\\Users\\me\\AppData\\Roaming',
|
|
LOCALAPPDATA: 'C:\\Users\\me\\AppData\\Local',
|
|
USERPROFILE: 'C:\\Users\\me',
|
|
PNPM_HOME: 'C:\\Users\\me\\AppData\\Local\\pnpm-home'
|
|
},
|
|
'win32'
|
|
)
|
|
const segments = env.Path?.split(';') ?? []
|
|
|
|
expect(segments).toEqual(
|
|
expect.arrayContaining([
|
|
'C:\\Users\\me\\AppData\\Roaming\\npm',
|
|
'C:\\Users\\me\\AppData\\Local\\pnpm',
|
|
'C:\\Users\\me\\.cargo\\bin',
|
|
'C:\\Users\\me\\.bun\\bin',
|
|
'C:\\Users\\me\\go\\bin',
|
|
'C:\\Users\\me\\.deno\\bin',
|
|
'C:\\Users\\me\\AppData\\Local\\pnpm-home'
|
|
])
|
|
)
|
|
})
|
|
|
|
it('honors relocated Windows package-manager env vars', () => {
|
|
const env = buildRelayCommandEnv(
|
|
{
|
|
Path: 'C:\\Tools',
|
|
CARGO_HOME: 'D:\\cargo',
|
|
BUN_INSTALL: 'D:\\bun',
|
|
DENO_INSTALL: 'D:\\deno',
|
|
GOBIN: 'D:\\go\\bin',
|
|
PNPM_HOME: 'D:\\pnpm'
|
|
},
|
|
'win32'
|
|
)
|
|
const segments = env.Path?.split(';') ?? []
|
|
|
|
expect(segments).toEqual(
|
|
expect.arrayContaining([
|
|
'D:\\cargo\\bin',
|
|
'D:\\bun\\bin',
|
|
'D:\\deno\\bin',
|
|
'D:\\go\\bin',
|
|
'D:\\pnpm'
|
|
])
|
|
)
|
|
})
|
|
|
|
it('deduplicates a user bin already present in the inherited PATH', () => {
|
|
const env = buildRelayCommandEnv({ HOME: '/home/me', PATH: '/home/me/.local/bin' }, 'linux')
|
|
const segments = env.PATH?.split(':') ?? []
|
|
|
|
expect(segments.filter((s) => s === '/home/me/.local/bin')).toHaveLength(1)
|
|
})
|
|
|
|
it('falls back to homedir() for user bins when the relay env carries no HOME', () => {
|
|
const env = buildRelayCommandEnv({ PATH: '/usr/bin' }, 'linux')
|
|
|
|
expect(env.PATH?.split(':')).toContain('/home/fallback/.local/bin')
|
|
expect(env.PATH).not.toContain('undefined')
|
|
})
|
|
|
|
it('adds only POSIX fallbacks when no home directory can be resolved', () => {
|
|
vi.mocked(homedir).mockReturnValueOnce('')
|
|
const env = buildRelayCommandEnv({ PATH: '' }, 'linux')
|
|
const segments = env.PATH?.split(':') ?? []
|
|
|
|
expect(segments).toEqual(expect.arrayContaining(['/usr/local/bin', '/usr/bin', '/bin']))
|
|
expect(segments.some((s) => s.includes('.local/bin'))).toBe(false)
|
|
})
|
|
|
|
it('keeps inherited PATH entries ahead of the appended fallbacks', () => {
|
|
const env = buildRelayCommandEnv({ HOME: '/home/me', PATH: '/custom/bin' }, 'linux')
|
|
const segments = env.PATH?.split(':') ?? []
|
|
|
|
expect(segments.indexOf('/custom/bin')).toBeLessThan(segments.indexOf('/usr/bin'))
|
|
})
|
|
|
|
it('orders the static POSIX fallbacks ahead of the resolved user bins', () => {
|
|
const env = buildRelayCommandEnv({ HOME: '/home/me', PATH: '' }, 'linux')
|
|
const segments = env.PATH?.split(':') ?? []
|
|
|
|
expect(segments.indexOf('/usr/bin')).toBeLessThan(segments.indexOf('/home/me/.local/bin'))
|
|
})
|
|
|
|
it('treats an empty-string HOME as absent and falls back to homedir()', () => {
|
|
const env = buildRelayCommandEnv({ HOME: '', PATH: '/usr/bin' }, 'linux')
|
|
|
|
expect(env.PATH?.split(':')).toContain('/home/fallback/.local/bin')
|
|
})
|
|
})
|
|
|
|
describe('buildRelayGitEnv', () => {
|
|
it('pins an untranslated locale on top of the command env (issue #7808)', () => {
|
|
// Relay git stderr/progress is machine-parsed; a non-English host locale
|
|
// with a gettext git would translate it and break the parsers.
|
|
const env = buildRelayGitEnv(
|
|
{ HOME: '/home/me', PATH: '/usr/bin', LC_ALL: 'de_DE.UTF-8' },
|
|
'linux'
|
|
)
|
|
|
|
expect(env.LC_ALL).toBe('en_US.UTF-8')
|
|
expect(env.LANG).toBe('en_US.UTF-8')
|
|
expect(env.LANGUAGE).toBe('en')
|
|
// PATH fallback behavior is unchanged.
|
|
expect(env.PATH?.split(':')).toEqual(expect.arrayContaining(['/usr/bin', '/usr/local/bin']))
|
|
})
|
|
})
|
|
|
|
describe('buildRelayUnattendedGitEnv', () => {
|
|
it('disables credential UI while preserving relay PATH, locale, and caller askpass', () => {
|
|
const env = buildRelayUnattendedGitEnv(
|
|
{
|
|
HOME: '/home/me',
|
|
PATH: '/custom/bin',
|
|
LC_ALL: 'de_DE.UTF-8',
|
|
GIT_ASKPASS: '/opt/noninteractive-credential-feeder'
|
|
},
|
|
'linux'
|
|
)
|
|
|
|
expect(env.GIT_TERMINAL_PROMPT).toBe('0')
|
|
expect(env.GCM_INTERACTIVE).toBe('never')
|
|
expect(env.GIT_ASKPASS).toBe('/opt/noninteractive-credential-feeder')
|
|
expect(env.GIT_CONFIG_COUNT).toBe('2')
|
|
expect(env.GIT_CONFIG_KEY_0).toBe('credential.interactive')
|
|
expect(env.GIT_CONFIG_VALUE_0).toBe('false')
|
|
expect(env.GIT_CONFIG_KEY_1).toBe('credential.guiPrompt')
|
|
expect(env.GIT_CONFIG_VALUE_1).toBe('false')
|
|
expect(env.GIT_SSH_COMMAND).toBe('ssh -o BatchMode=yes')
|
|
expect(env.LC_ALL).toBe('en_US.UTF-8')
|
|
expect(env.PATH?.split(':')).toEqual(expect.arrayContaining(['/custom/bin', '/usr/bin']))
|
|
})
|
|
})
|