From 77a223a7e2b5fb1058acfff38ec891268ef2a35a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 3 Jul 2026 19:04:16 -0700 Subject: [PATCH] Fix macOS TCC attribution for terminal children via login(1) (#7003) Co-authored-by: Orca --- src/main/daemon/pty-subprocess.test.ts | 12 +- src/main/daemon/pty-subprocess.ts | 7 +- src/main/ipc/pty.test.ts | 40 +++++ src/main/providers/local-pty-utils.test.ts | 102 +++++++++++- src/main/providers/local-pty-utils.ts | 11 +- .../providers/macos-tcc-login-shell.test.ts | 156 ++++++++++++++++++ src/main/providers/macos-tcc-login-shell.ts | 84 ++++++++++ 7 files changed, 399 insertions(+), 13 deletions(-) create mode 100644 src/main/providers/macos-tcc-login-shell.test.ts create mode 100644 src/main/providers/macos-tcc-login-shell.ts diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 5d34f57c9..98e0b863b 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -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 }) ) }) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index a4bd1fca7..6dd0554bb 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -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 { diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 26c1bda91..1cde7401c 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -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 diff --git a/src/main/providers/local-pty-utils.test.ts b/src/main/providers/local-pty-utils.test.ts index 7564860ac..66fde349d 100644 --- a/src/main/providers/local-pty-utils.test.ts +++ b/src/main/providers/local-pty-utils.test.ts @@ -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() 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') + }) +}) diff --git a/src/main/providers/local-pty-utils.ts b/src/main/providers/local-pty-utils.ts index c8eb87c24..53d016d31 100644 --- a/src/main/providers/local-pty-utils.ts +++ b/src/main/providers/local-pty-utils.ts @@ -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, diff --git a/src/main/providers/macos-tcc-login-shell.test.ts b/src/main/providers/macos-tcc-login-shell.test.ts new file mode 100644 index 000000000..b3adf6ea1 --- /dev/null +++ b/src/main/providers/macos-tcc-login-shell.test.ts @@ -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'] + }) + }) +}) diff --git a/src/main/providers/macos-tcc-login-shell.ts b/src/main/providers/macos-tcc-login-shell.ts new file mode 100644 index 000000000..283f8b867 --- /dev/null +++ b/src/main/providers/macos-tcc-login-shell.ts @@ -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=` 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 +): { 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] + } +}