LaunchAgent에서 iCloud를 못 읽는 이유 — TCC와 osascript 우회
봇이 옵시디언에 자동으로 기록을 남기게 만들었다.
이전 글에서 설명한 구조다. 매일 오전 9시에 나도봇 성과를 옵시디언 마크다운 파일로 저장한다. iCloud 경로에 쓰면 맥북과 아이폰에서 바로 열린다.
터미널에서 직접 실행하면 잘 된다.
LaunchAgent로 등록하면 안 된다.
무슨 일이 벌어지는가
에러 메시지는 없다. 그냥 파일이 안 생긴다.
로그를 뒤져보니 PermissionError: [Errno 1] Operation not permitted 가 나오고 있었다. iCloud 경로에 파일을 쓰려 했는데 권한이 없다는 뜻이다.
/Users/hun/Library/Mobile Documents/iCloud~md~obsidian/Documents/shud-brain/...
이 경로가 문제다.
macOS TCC란
TCC(Transparency, Consent, and Control)는 macOS의 프라이버시 보호 시스템이다.
사진, 연락처, 캘린더, iCloud Drive 같은 민감한 데이터에 접근하려면 사용자 동의가 필요하다. 앱이 처음 접근을 시도할 때 "이 앱이 접근하도록 허용하겠습니까?" 팝업이 뜬다.
터미널에서 직접 스크립트를 실행하면, 터미널 앱에 이미 Full Disk Access 권한이 있어서 통과된다.
LaunchAgent는 다르다. 백그라운드 프로세스로 실행되기 때문에 사용자 동의 팝업을 띄울 수가 없다. 그래서 iCloud 경로에 접근하면 무조건 막힌다.
해결책 후보들
Full Disk Access 부여: 시스템 설정 → 개인정보 보호 → 전체 디스크 접근 권한에 Python 인터프리터를 추가한다. 근데 pyenv로 설치한 Python은 경로가 달라서 까다롭다. 업데이트할 때마다 다시 설정해야 한다.
Webhook으로 우회: LaunchAgent에서 로컬 HTTP 서버로 요청을 보내고, 맥북에서 실행 중인 서버가 옵시디언에 쓴다. 너무 복잡하다.
osascript 우회: do shell script 명령어로 파일 읽기/쓰기를 실행한다. osascript는 다른 TCC 처리를 받는다.
세 번째 방법을 썼다.
osascript 우회 원리
import subprocess
import tempfile
import os
def read_icloud(path):
result = subprocess.run(
["osascript", "-e",
f'do shell script "cat " & quoted form of "{path}"'],
capture_output=True, text=True
)
if result.returncode != 0:
raise PermissionError(result.stderr)
return result.stdout
def write_icloud(path, content):
with tempfile.NamedTemporaryFile(mode='w', suffix='.md',
delete=False) as f:
f.write(content)
tmp_path = f.name
subprocess.run(
["osascript", "-e",
f'do shell script "cp " & quoted form of "{tmp_path}" & " " & quoted form of "{path}"'],
check=True
)
os.unlink(tmp_path)
cat 명령어로 파일을 읽고, cp 명령어로 파일을 쓴다. 둘 다 osascript의 do shell script 안에서 실행한다.
쓰기의 경우 /tmp 에 임시 파일을 만들고, cp로 iCloud 경로로 복사한다. /tmp는 TCC 제한을 받지 않는다.
LaunchAgent 환경변수 설정
LaunchAgent에는 PATH도 제대로 안 잡혀있다. osascript가 /usr/bin에 있는데 LaunchAgent가 그걸 못 찾을 수 있다.
plist에 명시적으로 설정해야 한다.
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin</string>
<key>HOME</key>
<string>/Users/hun</string>
</dict>
실제로 동작하는지 확인
처음에 쓰기만 osascript로 바꿨는데 여전히 안 됐다. 읽기도 막혀있었기 때문이다.
hot.md 를 읽어서 오늘 작업을 추가하는 코드가 있었는데, 읽기 단계에서도 같은 권한 에러가 났다. path.read_text() 를 전부 read_icloud(path) 로 바꿔야 했다.
모든 파일 접근을 osascript로 바꾼 뒤에 LaunchAgent에서도 정상 동작했다.
테스트 중 실수
테스트하다가 hot.md 내용을 "test" 한 단어로 덮어썼다.
osascript write 테스트할 때 내용을 실제 파일에 썼는데, 실수로 그게 hot.md 경로였다. 몇 주치 메모가 날아갔다.
다행히 iCloud 버전 기록이 남아있어서 복구했다. 테스트할 때는 임시 파일로 하는 게 맞다.
결론
LaunchAgent에서 iCloud 경로에 접근하면 TCC 때문에 막힌다. osascript의 do shell script로 파일 읽기/쓰기를 우회할 수 있다. 읽기와 쓰기 모두 바꿔야 한다.
복잡하게 느껴지지만 함수 두 개를 만들어놓고 기존 read_text(), write_text() 대신 쓰면 된다. 나머지 로직은 그대로다.