Fix macOS TCC attribution for terminal children via login(1) (#7003)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil 2026-07-03 19:04:16 -07:00 committed by GitHub
parent acfec3fd85
commit 77a223a7e2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 399 additions and 13 deletions

View File

@ -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 })
)
})

View File

@ -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 {

View File

@ -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

View File

@ -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')
})
})

View File

@ -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,

View File

@ -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']
})
})
})

View File

@ -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]
}
}