Fix macOS TCC attribution for terminal children via login(1) (#7003)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
acfec3fd85
commit
77a223a7e2
|
|
@ -254,9 +254,17 @@ describe('createPtySubprocess', () => {
|
|||
}
|
||||
}
|
||||
|
||||
// On macOS the shell is spawned through /usr/bin/login so terminal children
|
||||
// carry their own TCC identity (#6996); the real shell rides behind it, and
|
||||
// env(1) re-asserts the SHELL that login(1) would overwrite.
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'/bin/bash',
|
||||
expect.any(Array),
|
||||
'/usr/bin/login',
|
||||
expect.arrayContaining([
|
||||
'-flpq',
|
||||
'/usr/bin/env',
|
||||
expect.stringMatching(/^SHELL=/),
|
||||
'/bin/bash'
|
||||
]),
|
||||
expect.objectContaining({ cwd: originalCwd })
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
getNodePtySpawnHelperCandidates,
|
||||
validateWorkingDirectory
|
||||
} from '../providers/local-pty-utils'
|
||||
import { wrapShellSpawnForMacosTccAttribution } from '../providers/macos-tcc-login-shell'
|
||||
import { resolveWindowsShellLaunchArgs } from '../providers/windows-shell-args'
|
||||
import {
|
||||
resolveEffectiveWindowsPowerShell,
|
||||
|
|
@ -478,8 +479,9 @@ function spawnDaemonPtyWithWindowsFallback(args: {
|
|||
spawnCwd: string
|
||||
startupCommandDeliveredInShellArgs?: boolean
|
||||
} {
|
||||
const spawnAt = (shellPath: string, shellArgs: string[], cwd: string): pty.IPty =>
|
||||
pty.spawn(shellPath, shellArgs, {
|
||||
const spawnAt = (shellPath: string, shellArgs: string[], cwd: string): pty.IPty => {
|
||||
const wrapped = wrapShellSpawnForMacosTccAttribution(shellPath, shellArgs, args.env)
|
||||
return pty.spawn(wrapped.file, wrapped.args, {
|
||||
name: args.env.TERM ?? 'xterm-256color',
|
||||
cols: args.cols,
|
||||
rows: args.rows,
|
||||
|
|
@ -489,6 +491,7 @@ function spawnDaemonPtyWithWindowsFallback(args: {
|
|||
// legacy system ConPTY can corrupt full-width TUI rows in scrollback.
|
||||
...(process.platform === 'win32' ? { useConptyDll: true } : {})
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
one focused file because the registration helper is stateful and each spawn-path
|
||||
assertion reuses the same mocked IPC and node-pty harness. */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { userInfo } from 'node:os'
|
||||
import { delimiter, join, posix } from 'node:path'
|
||||
import {
|
||||
TERMINAL_INPUT_CHUNK_MAX_BYTES,
|
||||
|
|
@ -251,6 +252,7 @@ describe('registerPtyHandlers', () => {
|
|||
const savedOrcaOmpStatusExtension = process.env.ORCA_OMP_STATUS_EXTENSION
|
||||
const savedOrcaClaudeAgentStatusSettings = process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS
|
||||
const savedProcessPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
const savedDisableMacosLoginShell = process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL
|
||||
|
||||
beforeEach(() => {
|
||||
// Why: most PTY spawn tests assert POSIX shell behavior; Windows-specific
|
||||
|
|
@ -259,6 +261,10 @@ describe('registerPtyHandlers', () => {
|
|||
configurable: true,
|
||||
value: 'darwin'
|
||||
})
|
||||
// Why: with platform forced to darwin, the TCC login(1) wrapper would
|
||||
// rewrite every spawn argv these tests assert. Its own integration test
|
||||
// below re-enables it.
|
||||
process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL = '1'
|
||||
delete process.env.OPENCODE_CONFIG_DIR
|
||||
delete process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR
|
||||
delete process.env.ORCA_OPENCODE_CONFIG_DIR
|
||||
|
|
@ -368,6 +374,11 @@ describe('registerPtyHandlers', () => {
|
|||
if (savedProcessPlatform) {
|
||||
Object.defineProperty(process, 'platform', savedProcessPlatform)
|
||||
}
|
||||
if (savedDisableMacosLoginShell !== undefined) {
|
||||
process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL = savedDisableMacosLoginShell
|
||||
} else {
|
||||
delete process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL
|
||||
}
|
||||
if (savedOpenCodeConfigDir !== undefined) {
|
||||
process.env.OPENCODE_CONFIG_DIR = savedOpenCodeConfigDir
|
||||
} else {
|
||||
|
|
@ -5907,6 +5918,35 @@ describe('registerPtyHandlers', () => {
|
|||
}
|
||||
})
|
||||
|
||||
posixOnlyIt('wraps macOS spawns in login(1) with SHELL re-asserted via env(1)', async () => {
|
||||
const originalShell = process.env.SHELL
|
||||
// Re-enable the TCC login wrapper the suite-level beforeEach disables.
|
||||
delete process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL
|
||||
process.env.SHELL = '/bin/zsh'
|
||||
|
||||
try {
|
||||
const [file, args, options] = await spawnAndGetCall({ cwd: '/tmp' })
|
||||
expect(file).toBe('/usr/bin/login')
|
||||
expect(args).toEqual([
|
||||
'-flpq',
|
||||
userInfo().username,
|
||||
'/usr/bin/env',
|
||||
'SHELL=/bin/zsh',
|
||||
'/bin/zsh',
|
||||
'-l'
|
||||
])
|
||||
// The spawn env keeps the real shell so identity/name logic is intact.
|
||||
expect(options.env.SHELL).toBe('/bin/zsh')
|
||||
} finally {
|
||||
process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL = '1'
|
||||
if (originalShell === undefined) {
|
||||
delete process.env.SHELL
|
||||
} else {
|
||||
process.env.SHELL = originalShell
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the POSIX shell wrapper so OpenCode config survives shell startup files', async () => {
|
||||
const originalPlatform = process.platform
|
||||
const originalShell = process.env.SHELL
|
||||
|
|
|
|||
|
|
@ -2,18 +2,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|||
import type * as fs from 'node:fs'
|
||||
import type { Stats } from 'node:fs'
|
||||
|
||||
const { existsSyncMock, statSyncMock, wslUncDirectoryExistsMock } = vi.hoisted(() => ({
|
||||
existsSyncMock: vi.fn(),
|
||||
statSyncMock: vi.fn(),
|
||||
wslUncDirectoryExistsMock: vi.fn()
|
||||
}))
|
||||
const { existsSyncMock, statSyncMock, accessSyncMock, wslUncDirectoryExistsMock, wrapSpawnMock } =
|
||||
vi.hoisted(() => ({
|
||||
existsSyncMock: vi.fn(),
|
||||
statSyncMock: vi.fn(),
|
||||
accessSyncMock: vi.fn(),
|
||||
wslUncDirectoryExistsMock: vi.fn(),
|
||||
wrapSpawnMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof fs>()
|
||||
return {
|
||||
...actual,
|
||||
existsSync: existsSyncMock,
|
||||
statSync: statSyncMock
|
||||
statSync: statSyncMock,
|
||||
accessSync: accessSyncMock
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -25,7 +29,11 @@ vi.mock('../wsl', () => ({
|
|||
wslUncDirectoryExists: wslUncDirectoryExistsMock
|
||||
}))
|
||||
|
||||
import { validateWorkingDirectory } from './local-pty-utils'
|
||||
vi.mock('./macos-tcc-login-shell', () => ({
|
||||
wrapShellSpawnForMacosTccAttribution: wrapSpawnMock
|
||||
}))
|
||||
|
||||
import { spawnShellWithFallback, validateWorkingDirectory } from './local-pty-utils'
|
||||
|
||||
const WSL_UNC_DIR = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'
|
||||
const NATIVE_DIR = 'C:\\Users\\jin\\repo'
|
||||
|
|
@ -93,3 +101,83 @@ describe('validateWorkingDirectory', () => {
|
|||
expect(() => validateWorkingDirectory(NATIVE_DIR)).toThrow(/is not a directory/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('spawnShellWithFallback macOS TCC login wrapping', () => {
|
||||
let origPlatform: PropertyDescriptor | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
origPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
statSyncMock.mockReturnValue(dirStats(true))
|
||||
accessSyncMock.mockReturnValue(undefined)
|
||||
// Emulate the real wrapper: prepend /usr/bin/login in front of the shell.
|
||||
wrapSpawnMock.mockImplementation((file: string, args: string[]) => ({
|
||||
file: '/usr/bin/login',
|
||||
args: ['-flpq', 'ada', file, ...args]
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (origPlatform) {
|
||||
Object.defineProperty(process, 'platform', origPlatform)
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('spawns the primary shell through the login wrapper', () => {
|
||||
const ptySpawn = vi.fn().mockReturnValue({ pid: 1 })
|
||||
|
||||
const result = spawnShellWithFallback({
|
||||
shellPath: '/bin/zsh',
|
||||
shellArgs: ['-l'],
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/work',
|
||||
env: {},
|
||||
ptySpawn: ptySpawn as never
|
||||
})
|
||||
|
||||
expect(wrapSpawnMock).toHaveBeenCalledWith('/bin/zsh', ['-l'], expect.any(Object))
|
||||
expect(ptySpawn).toHaveBeenCalledWith(
|
||||
'/usr/bin/login',
|
||||
['-flpq', 'ada', '/bin/zsh', '-l'],
|
||||
expect.objectContaining({ cwd: '/work', cols: 80, rows: 24 })
|
||||
)
|
||||
// The reported shellPath stays the real shell so identity/name logic is intact.
|
||||
expect(result.shellPath).toBe('/bin/zsh')
|
||||
})
|
||||
|
||||
it('wraps fallback shells too when the primary fails to spawn', () => {
|
||||
const ptySpawn = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('primary boom')
|
||||
})
|
||||
.mockReturnValue({ pid: 2 })
|
||||
|
||||
const result = spawnShellWithFallback({
|
||||
shellPath: '/bin/zsh',
|
||||
shellArgs: ['-l'],
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/work',
|
||||
env: {},
|
||||
ptySpawn: ptySpawn as never
|
||||
})
|
||||
|
||||
// First fallback candidate after /bin/zsh is /bin/bash, also login-wrapped.
|
||||
// The wrapper must see the fallback-corrected env so SHELL survives login(1).
|
||||
expect(wrapSpawnMock).toHaveBeenLastCalledWith(
|
||||
'/bin/bash',
|
||||
['-l'],
|
||||
expect.objectContaining({ SHELL: '/bin/bash' })
|
||||
)
|
||||
expect(ptySpawn).toHaveBeenLastCalledWith(
|
||||
'/usr/bin/login',
|
||||
['-flpq', 'ada', '/bin/bash', '-l'],
|
||||
expect.objectContaining({ cwd: '/work' })
|
||||
)
|
||||
expect(result.shellPath).toBe('/bin/bash')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { existsSync, accessSync, statSync, chmodSync, constants as fsConstants }
|
|||
import type * as pty from 'node-pty'
|
||||
import { isWslUncPath } from '../../shared/wsl-paths'
|
||||
import { wslUncDirectoryExists } from '../wsl'
|
||||
import { wrapShellSpawnForMacosTccAttribution } from './macos-tcc-login-shell'
|
||||
|
||||
let didEnsureSpawnHelperExecutable = false
|
||||
|
||||
|
|
@ -225,8 +226,9 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu
|
|||
|
||||
if (!primaryError) {
|
||||
try {
|
||||
const wrapped = wrapShellSpawnForMacosTccAttribution(shellPath, shellArgs, env)
|
||||
return {
|
||||
process: ptySpawn(shellPath, shellArgs, {
|
||||
process: ptySpawn(wrapped.file, wrapped.args, {
|
||||
name: termName,
|
||||
cols,
|
||||
rows,
|
||||
|
|
@ -260,7 +262,12 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu
|
|||
env.SHELL = fallback
|
||||
onBeforeFallbackSpawn?.(env, fallback)
|
||||
Object.assign(env, fallbackReady?.env ?? {})
|
||||
const proc = ptySpawn(fallback, fallbackReady?.args ?? ['-l'], {
|
||||
const wrapped = wrapShellSpawnForMacosTccAttribution(
|
||||
fallback,
|
||||
fallbackReady?.args ?? ['-l'],
|
||||
env
|
||||
)
|
||||
const proc = ptySpawn(wrapped.file, wrapped.args, {
|
||||
name: termName,
|
||||
cols,
|
||||
rows,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,156 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { existsSyncMock, userInfoMock } = vi.hoisted(() => ({
|
||||
existsSyncMock: vi.fn(),
|
||||
userInfoMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:fs', () => ({ existsSync: existsSyncMock }))
|
||||
vi.mock('node:os', () => ({ userInfo: userInfoMock }))
|
||||
|
||||
import { wrapShellSpawnForMacosTccAttribution } from './macos-tcc-login-shell'
|
||||
|
||||
describe('wrapShellSpawnForMacosTccAttribution', () => {
|
||||
let origPlatform: PropertyDescriptor | undefined
|
||||
let origDisable: string | undefined
|
||||
|
||||
function setPlatform(value: string): void {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
origPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
origDisable = process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL
|
||||
delete process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
userInfoMock.mockReturnValue({ username: 'ada' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (origPlatform) {
|
||||
Object.defineProperty(process, 'platform', origPlatform)
|
||||
}
|
||||
if (origDisable === undefined) {
|
||||
delete process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL
|
||||
} else {
|
||||
process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL = origDisable
|
||||
}
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('wraps the shell in /usr/bin/login on macOS, preserving the shell args behind it', () => {
|
||||
setPlatform('darwin')
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'])).toEqual({
|
||||
file: '/usr/bin/login',
|
||||
args: ['-flpq', 'ada', '/usr/bin/env', 'SHELL=/bin/zsh', '/bin/zsh', '-l']
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps bash rcfile args intact after the shell path', () => {
|
||||
setPlatform('darwin')
|
||||
expect(
|
||||
wrapShellSpawnForMacosTccAttribution('/bin/bash', ['--rcfile', '/orca/bash/rcfile'])
|
||||
).toEqual({
|
||||
file: '/usr/bin/login',
|
||||
args: [
|
||||
'-flpq',
|
||||
'ada',
|
||||
'/usr/bin/env',
|
||||
'SHELL=/bin/bash',
|
||||
'/bin/bash',
|
||||
'--rcfile',
|
||||
'/orca/bash/rcfile'
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('re-asserts the spawn env SHELL that login(1) would overwrite', () => {
|
||||
setPlatform('darwin')
|
||||
expect(
|
||||
wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'], { SHELL: '/opt/homebrew/bin/fish' })
|
||||
).toEqual({
|
||||
file: '/usr/bin/login',
|
||||
args: ['-flpq', 'ada', '/usr/bin/env', 'SHELL=/opt/homebrew/bin/fish', '/bin/zsh', '-l']
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the spawned shell for SHELL when the env value is empty', () => {
|
||||
setPlatform('darwin')
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'], { SHELL: '' })).toEqual({
|
||||
file: '/usr/bin/login',
|
||||
args: ['-flpq', 'ada', '/usr/bin/env', 'SHELL=/bin/zsh', '/bin/zsh', '-l']
|
||||
})
|
||||
})
|
||||
|
||||
it('skips the env(1) interposition when the shell path would parse as an assignment', () => {
|
||||
setPlatform('darwin')
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/odd=dir/zsh', ['-l'])).toEqual({
|
||||
file: '/usr/bin/login',
|
||||
args: ['-flpq', 'ada', '/odd=dir/zsh', '-l']
|
||||
})
|
||||
})
|
||||
|
||||
it('still wraps with login when /usr/bin/env is missing, without interposition', () => {
|
||||
setPlatform('darwin')
|
||||
existsSyncMock.mockImplementation((path: string) => path === '/usr/bin/login')
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'])).toEqual({
|
||||
file: '/usr/bin/login',
|
||||
args: ['-flpq', 'ada', '/bin/zsh', '-l']
|
||||
})
|
||||
})
|
||||
|
||||
it('is a no-op on non-macOS platforms', () => {
|
||||
setPlatform('linux')
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'])).toEqual({
|
||||
file: '/bin/zsh',
|
||||
args: ['-l']
|
||||
})
|
||||
})
|
||||
|
||||
it('is idempotent when the file is already /usr/bin/login', () => {
|
||||
setPlatform('darwin')
|
||||
const args = ['-flpq', 'ada', '/bin/zsh', '-l']
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/usr/bin/login', args)).toEqual({
|
||||
file: '/usr/bin/login',
|
||||
args
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the plain spawn when the login binary is missing', () => {
|
||||
setPlatform('darwin')
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'])).toEqual({
|
||||
file: '/bin/zsh',
|
||||
args: ['-l']
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the plain spawn when the username cannot be resolved', () => {
|
||||
setPlatform('darwin')
|
||||
userInfoMock.mockImplementation(() => {
|
||||
throw new Error('no user')
|
||||
})
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'])).toEqual({
|
||||
file: '/bin/zsh',
|
||||
args: ['-l']
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the plain spawn when the username is empty', () => {
|
||||
setPlatform('darwin')
|
||||
userInfoMock.mockReturnValue({ username: '' })
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'])).toEqual({
|
||||
file: '/bin/zsh',
|
||||
args: ['-l']
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the plain spawn when disabled via env', () => {
|
||||
setPlatform('darwin')
|
||||
process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL = '1'
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l'])).toEqual({
|
||||
file: '/bin/zsh',
|
||||
args: ['-l']
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import { existsSync } from 'node:fs'
|
||||
import { userInfo } from 'node:os'
|
||||
|
||||
const MACOS_LOGIN_PATH = '/usr/bin/login'
|
||||
const MACOS_ENV_PATH = '/usr/bin/env'
|
||||
|
||||
/**
|
||||
* Env escape hatch to force the plain (unwrapped) spawn. Set to `1`/`true` if a
|
||||
* user's environment misbehaves under login(1); terminals fall back to today's
|
||||
* direct-spawn behavior.
|
||||
*/
|
||||
const DISABLE_ENV_VAR = 'ORCA_DISABLE_MACOS_LOGIN_SHELL'
|
||||
|
||||
function isDisabledByEnv(): boolean {
|
||||
const value = process.env[DISABLE_ENV_VAR]
|
||||
return value === '1' || value === 'true'
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a macOS POSIX shell spawn in `/usr/bin/login` so terminal children carry
|
||||
* their own TCC identity instead of collapsing into Orca's bundle identifier.
|
||||
*
|
||||
* Why: when Orca spawns a shell directly, macOS attributes a spawned CLI's
|
||||
* "access other apps' data" request (kTCCServiceSystemPolicyAppData) to Orca's
|
||||
* bundle id and never persists the grant, so signed CLIs like `op` re-prompt on
|
||||
* every launch (#6996). Native terminals (Terminal.app and others) launch shells
|
||||
* through login(1), which lets tccd resolve each child's own code identity and
|
||||
* remember the decision. This matches that spawn shape without a native patch.
|
||||
*
|
||||
* Flags: -f (skip auth; we are the logged-in user relaunching as ourselves),
|
||||
* -l (do not chdir to home — node-pty already set cwd — and skip the login
|
||||
* dash-argv0 marker), -p (preserve Orca's env, including its ZDOTDIR shell
|
||||
* integration), -q (suppress the login banner so wrapped terminals look
|
||||
* unchanged). The underlying shell keeps its own args (e.g. zsh's `-l`) so
|
||||
* login-shell behavior is unchanged.
|
||||
*
|
||||
* SHELL: even under -p, login(1) overwrites SHELL with the account shell from
|
||||
* the user database, while Orca terminals deliberately export the shell they
|
||||
* actually run (fallback shells, custom shell settings). Interposing
|
||||
* `/usr/bin/env SHELL=<shell>` between login and the shell re-asserts the
|
||||
* intended value — and, as a same-process exec, does not disturb login's TCC
|
||||
* attribution. Skipped only if the shell path itself contains `=`, which env(1)
|
||||
* would misparse as an assignment.
|
||||
*
|
||||
* No-op off macOS, when already wrapped, when the login binary or username is
|
||||
* unavailable, or when disabled via {@link DISABLE_ENV_VAR}, so terminal
|
||||
* spawning never regresses.
|
||||
*/
|
||||
export function wrapShellSpawnForMacosTccAttribution(
|
||||
file: string,
|
||||
args: string[],
|
||||
env?: Record<string, string | undefined>
|
||||
): { file: string; args: string[] } {
|
||||
if (process.platform !== 'darwin') {
|
||||
return { file, args }
|
||||
}
|
||||
if (file === MACOS_LOGIN_PATH || isDisabledByEnv()) {
|
||||
return { file, args }
|
||||
}
|
||||
if (!existsSync(MACOS_LOGIN_PATH)) {
|
||||
return { file, args }
|
||||
}
|
||||
|
||||
let username: string
|
||||
try {
|
||||
username = userInfo().username
|
||||
} catch {
|
||||
return { file, args }
|
||||
}
|
||||
if (!username) {
|
||||
return { file, args }
|
||||
}
|
||||
|
||||
const shellEnvValue = env?.SHELL || file
|
||||
const interposedShellEnv =
|
||||
!file.includes('=') && existsSync(MACOS_ENV_PATH)
|
||||
? [MACOS_ENV_PATH, `SHELL=${shellEnvValue}`]
|
||||
: []
|
||||
|
||||
return {
|
||||
file: MACOS_LOGIN_PATH,
|
||||
args: ['-flpq', username, ...interposedShellEnv, file, ...args]
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue