본문 바로가기
C.W.K.
Stream
Lesson 02 of 05 · published

ssh가 못 여는 키체인

~14 min · macos, launchd, keychain, ssh, debugging

Level 0메모 쪽지
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

실행 세 번, 네이티브 관문 세 개

지난 레슨의 헬퍼는 첫날부터 돌아갔어. 워커는 안 돌았고. 2026-10-08에 엔진이 laptop에서 Claude Code 세션을 세 번 띄우려 했는데, 매번 다른 관문에서 멈췄어. 셋 다 Coop 게 아니라 네이티브 CLI 거야.

  1. 신뢰 안 된 폴더. 첫 실행은 "Workspace not trusted. Run claude in <folder> once and accept the trust prompt."로 끝났어. 아빠가 laptop에서 그 저장소를 Desktop 앱으로만 열어봤거든. CLI는 폴더마다 신뢰를 따로 기억해.
  2. 로그인. 두 번째 실행은 세션까지는 떴는데 CLI 자체의 "Login expired · Please run /login"에서 멈췄어. CLI 자격 증명은 Desktop 앱이랑 따로 놀아서, 아빠가 그 맥에서 CLI 로그인을 했지.
  3. 또 같은 로그인. 아빠가 /login 하고 2분 뒤, 세 번째 실행이 토씨 하나 안 틀리고 같은 줄에서 멈췄어.

앞의 둘은 네이티브 관문이고, 엔진은 생김새에 따라 따로 다뤄. 신뢰 안 된 폴더처럼 CLI가 실행을 대놓고 거부하면 아무것도 안 떠. 그래서 배정은 네이티브 사유를 보여주고, 준비 창 안에서 30초마다 실행을 다시 시도해. 아빠가 그 맥에서 신뢰 창을 한 번 수락하면 다음 시도가 통과하고. 로그인 관문은 생김새가 달라. 세션이 뜨고 나서 멈추거든. 발견 단계가 백그라운드 작업 상태를 읽다가 blocked를 보면, 배정을 네이티브 사유와 함께 바로 실패 처리해. 아무리 다시 시도해도 그 관문은 못 넘으니까. 아빠가 그 맥에서 풀고 다시 배정하면 돼. 엔진은 신뢰 창이든 로그인 창이든 절대 직접 답하지 않아. 그런 관문은 사람한테 뭔가를 결정하게 하려고 있는 거야. 그걸 클릭해서 넘기는 조율자는 슬그머니 그 사람이 돼버린 거고.

세 번째는 관문이 아니었어. 측정을 기다리던 문제였지.

측정이 한 말

아빠는 방금 로그인했어. CLI는 로그인이 만료됐대. 둘 다 참이려면, 묻는 프로세스가 로그인이 저장해 둔 걸 볼 수 없어야 해. 숫자가 보여준 게 정확히 그거야.

  • ssh 세션에서 태어난 프로세스에선 security가 "user interaction is not allowed"를 남기고 36으로 끝나. 사용자 로그인 키체인을 못 읽는 거야.
  • claude auth status는 ssh에선 loggedIn false, 사용자의 gui/<uid> launchd 도메인에서 돈 작업에선 loggedIn true. 같은 맥, 같은 사용자, 같은 순간이야.

자격 증명은 멀쩡했어. 틀린 건 프로세스가 태어난 세션이었지. Claude Code는 로그인을 키체인에 두고, ssh에서 태어난 프로세스는 그 키체인을 읽을 수 있는 보안 맥락 바깥에 살아.

덕분에 아무도 굳이 설명하려 들지 않던 사실 하나도 풀렸어. office가 제 맥에서 돌린 실행은 늘 잘 됐잖아. office의 Coop 런타임은 GUI 도메인의 launchd 에이전트라서, 걔가 띄우는 건 전부 처음부터 거기서 태어나. laptop 실행이 다른 데서 태어난 첫 사례였던 거야. 참고로 Codex는 로그인을 홈 디렉터리 아래 평범한 파일에 둬서, ssh에서도 아무 불평 없이 읽혀. 이 벽은 Claude 혼자 만난 거야.

해법: 작업 하나 동안 GUI 세션을 빌려

커밋 875fddf가 헬퍼한테 키체인이 읽히는 곳에서 실행하라고 가르쳐. 명령을 바로 띄우는 대신 launch_in_gui가 이렇게 해.

  1. 일회용 launchd 작업을 써. 고유 라벨, 명령, 작업 디렉터리, RunAtLoad와 AbandonProcessGroup, 출력용 파일 둘이 들어간 프로퍼티 리스트야.
  2. launchctl bootstrap gui/<uid>로 올려. 사용자 GUI 세션에서 바로 시작돼.
  3. 작업이 더는 실행 중이 아니라고 하고 종료 코드를 내놓을 때까지 0.5초마다 launchctl print로 들여다봐.
  4. bootout으로 내리고, 출력 끝부분을 읽고, 임시 파일은 전부 지워.

숨은 주인공은 AbandonProcessGroup야. launchd 작업이 끝나면 launchd는 보통 그 작업의 프로세스 그룹에 남은 걸 다 죽여. 근데 실행 명령이 claude --bg잖아. 명령이 돌아온 뒤에도 백그라운드 세션을 살려두라고 있는 명령이야. 이 키가 없으면 세션이 자기를 띄운 작업이랑 같이 죽어.

GUI 세션이 작업을 안 받으면(예를 들어 그 맥에 아무도 로그인 안 해 있으면) bootstrap이 실패하고, 헬퍼는 직접 띄우기로 물러나. 답에는 어느 맥락에서 돌았는지(gui인지 direct인지), GUI 경로가 왜 안 됐는지가 같이 실려. 저널에도 남고. 흔적 없는 대체 경로는 다음 "Login expired"를 다시 미스터리로 만들어버리거든.

아무도 타자 안 친 첫 카나리아

그 수정이 office에서 돌기 시작하자(런타임 875fddf, laptop엔 Claude Code 2.1.293), 카나리아 todo #349가 워크스페이스에서 laptop으로 배정됐어. 실행은 GUI 도메인 작업으로 돌았어. 발견 단계가 Remote Control 브리지 달린 새 세션을 찾았고, 초대가 나가자 세션이 수락했어. 그리고 todo가 요구한 호스트 이름, 함대 라벨, 체크아웃 HEAD를 담아 done을 보고했지. 배정부터 보고까지 1분도 안 걸렸어. 기록은 담담하게 적어. laptop에선 아무도 타자를 안 쳤다고.

이 카나리아가 뭘 증명하고 뭘 증명 안 하는지 짚고 가. 다른 맥에서 새 세션을 띄우고, 찾고, 받아들이고, 보고를 듣는 게 된다는 건 증명해. 앞의 두 관문이 사라졌다는 건 증명 못 해. 신뢰는 여전히 폴더마다, 로그인은 여전히 맥마다야. 다음 새 저장소, 다음 새 laptop에서 또 멈춰. 이번에도 불변식 10이야. 주장마다 증거가 따로 필요해.

자격 증명은 네가 어떤 사용자냐가 아니라, 너를 돌리는 세션에 붙어 있어. 명령이 터미널에선 되고 서비스로는 안 되면 명령 디버깅은 그만둬. 두 출생지를 비교해.

Code

엔진의 remote_helper.py: GUI 도메인의 일회용 작업으로 돌리는 실행 (줄인 발췌)·python
def launch_in_gui(argv, cwd, timeout):
    """Run the launch as a one-shot launchd job in this user's GUI session.

    A process born in an ssh session cannot read the user's login keychain
    (`security` exits 36, "user interaction is not allowed"), so Claude Code
    started there is logged out even right after Dad's /login: on 2026-10-08
    `claude auth status` said loggedIn false over ssh and loggedIn true from a
    job in `gui/<uid>`. Office's own launches already run in that domain,
    because Coop's runtime is a GUI launchd agent. The job runs the command and
    exits; AbandonProcessGroup keeps the background session alive after it.
    """
    ...
    target = "gui/%d/%s" % (uid, label)
    try:
        bootstrap = subprocess.run(["/bin/launchctl", "bootstrap", "gui/%d" % uid, plist], capture_output=True, text=True, timeout=15)
        if bootstrap.returncode:
            return None, (bootstrap.stderr or bootstrap.stdout).strip()[:300]
        deadline = time.time() + float(timeout)
        code = None
        while time.time() < deadline:
            printed = subprocess.run(["/bin/launchctl", "print", target], capture_output=True, text=True, timeout=10)
            finished, code = job_state(printed.stdout)
            if finished:
                break
            time.sleep(0.5)
        else:
            subprocess.run(["/bin/launchctl", "bootout", target], capture_output=True, timeout=15)
            raise subprocess.TimeoutExpired(argv[0], timeout)
        subprocess.run(["/bin/launchctl", "bootout", target], capture_output=True, timeout=15)
        return {"returncode": 0 if code is None else code, "stdout": _read_tail(out), "stderr": _read_tail(err),
                "context": "gui"}, None
    finally:
        ...  # remove the plist, the output files and the temporary directory
엔진의 remote_helper.py: 어느 맥락에서 돌았는지 밝히는 대체 경로 (줄인 발췌)·python
def launch(cwd, argv, timeout, context="gui"):
    ...
    command = [executable] + argv[1:]
    fallback = None
    if context == "gui" and os.path.exists("/bin/launchctl"):
        result, fallback = launch_in_gui(command, cwd, timeout)
        if result is not None:
            return result
    completed = subprocess.run(command, cwd=cwd, stdin=subprocess.DEVNULL, capture_output=True,
                               text=True, timeout=float(timeout), start_new_session=True)
    return {"returncode": completed.returncode, "stdout": completed.stdout[-OUTPUT_LIMIT:],
            "stderr": completed.stderr[-OUTPUT_LIMIT:], "context": "direct",
            **({"gui_unavailable": fallback} if fallback else {})}

External links

Exercise

네 맥에서 로그인을 키체인에 두는 CLI 하나를 골라. Claude Code를 쓰면 claude auth status가 그런 거야. 그 상태 명령을 세 가지 방법으로 돌려봐. 터미널에서, ssh localhost로, 그리고 gui/$(id -u)에 bootstrap 하는 일회용 launchd 작업으로. 마지막은 출력을 파일로 보내. 세 답을 적어. ssh만 다르게 답하면 이 레슨의 측정을 네 기계에서 재현한 거야.
Hint
최소한의 작업엔 Label, ProgramArguments, RunAtLoad, StandardOutPath면 돼. launchctl bootstrap gui/$(id -u) job.plist로 올리고, launchctl print gui/$(id -u)/<라벨>에 더 이상 실행 중으로 안 나오면 출력 파일을 읽고, launchctl bootout gui/$(id -u)/<라벨>로 내려. ssh 쪽을 하려면 시스템 설정에서 원격 로그인을 켜둬야 해.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.