
AI 코딩 에이전트를 위한 원격 제어 장치를 만들었습니다 — CorvusTunnel의 엔지니어링 비하인드
요약
AI 코딩 에이전트 사용 중 발생하는 '인간의 승인(Human-in-the-loop)' 문제를 해결하기 위한 원격 터미널 도구 CorvusTunnel의 개발 과정을 다룹니다. NAT 트래버설 문제를 해결하기 위해 릴레이 서버 패턴을 채택하여 다양한 에이전트와 호환되는 보안 터널을 구축했습니다.
핵심 포인트
- AI 에이전트 작업 중 발생하는 승인 대기 문제를 해결하기 위한 도구 개발
- 특정 에이전트에 종속되지 않고 터미널 기반의 범용적 접근 방식 채택
- NAT 트래버설 문제를 해결하기 위해 WebSocket 릴레이 패턴 사용
- 계정 생성이나 복잡한 포트 포워딩 없이 QR 코드로 간편한 연결 지원
지난달, 저는 Claude Code가 기다리고 있는 파일 변경을 승인하기 위해 휴대폰으로 제 워크스테이션에 SSH 접속을 하고 있는 제 자신을 발견했습니다. 저는 카페에 있었고, 제 노트북은 집에서 긴 리팩터링 (refactor)을 수행 중이었으며, 에이전트는 예/아니오 답변이 필요했기 때문에 40분 동안 차단된 상태였습니다.
그렇다고 모든 것에 --dangerously-skip-permissions 옵션을 붙이고 싶지도 않았습니다. 그렇게 하더라도, 에이전트에게 다음 작업을 전달하기 위해 여전히 PC 앞에 앉아 있어야 했습니다. 에이전트는 작업을 마치고 지시를 기다리는데, 저는 마치 2019년인 것처럼 책상 앞에 묶여 있어야 했습니다.
그 순간 저는 CorvusTunnel을 만들기로 결심했습니다.
절반만 해결된 문제
AI 코딩 에이전트들은 점점 좋아지고 있습니다. 제가 자리를 비운 사이에도 계속 실행해 둘 수 있을 정도로 말이죠. Claude Code, Codex CLI, Antigravity — 이들은 모두 어느 시점에는 루프 내의 인간 (human in the loop)을 필요로 합니다. 파일 생성, 함수 삭제, 마이그레이션 (migration) 실행 등을 원하며, 그리고 기다립니다.
이미 누군가 이 문제의 일부를 해결했습니다. Claude는 자체적인 원격 제어 기능이 있습니다. Codex도 마찬가지입니다. Happy Coder도 존재합니다. 하지만 이들 각각은 자신의 에이전트와만 작동합니다. 만약 오전에는 Claude Code를 사용하고 오후에는 Codex를 사용한다면 — 또는 동일한 프로젝트에서 세 가지를 모두 평가하고 있다면 — 별도의 도구, 별도의 설정, 별도의 휴대폰 앱이 필요합니다.
저는 이 모든 에이전트에 한 번에 도달할 수 있는 하나의 도구를 원했습니다. 명령어를 실행하고, QR 코드를 스캔하면, 종단간 암호화 (end-to-end encrypted)된 실제 터미널이 제 휴대폰에 나타나는 방식 말이죠. 계정도, 포트 포워딩 (port forwarding)도, Docker도 필요 없습니다. CorvusTunnel은 어떤 에이전트를 실행 중이든 상관없이 그 에이전트와 통신합니다. 왜냐하면 그것이 어떤 에이전트인지 상관하지 않고, 그저 터미널일 뿐이기 때문입니다.
아키텍처: 릴레이 패턴 (relay pattern)
"휴대폰을 컴퓨터에 연결하는 것"에서 가장 어려운 부분은 NAT 트래버설 (NAT traversal)입니다. 당신의 홈 머신은 라우터 뒤에 있고, 휴대폰은 셀룰러 네트워크에 있으며, 둘 중 어느 것도 서로에게 직접 도달할 수 없습니다.
저는 세 가지 접근 방식을 고려했습니다:
옵션 1: 포트 포워딩 (Port forwarding). 사용자가 자신의 라우터에서 포트를 개방합니다. 이는 비전문가에게는 까다로우며, 제가 만들고 싶지 않았던 보안 취약점 (security surface)을 생성합니다.
옵션 2: Cloudflare Tunnel. cloudflared가 사용자의 머신으로부터 아웃바운드 터널 (outbound tunnel)을 생성합니다. 이 방식은 작동하지만, 별도의 바이너리를 설치해야 하며 Cloudflare 계정이 필요합니다. 저는 이를 --no-relay 폴백 (fallback) 옵션으로 남겨두었습니다.
옵션 3: 릴레이 서버 (A relay server). 양측 모두 가벼운 WebSocket 릴레이에 아웃바운드로 연결합니다. 릴레이는 키를 보유하지 않으며 불투명한 바이트 (opaque bytes)를 전달할 뿐입니다. 이것이 CorvusTunnel이 기본적으로 수행하는 방식입니다.
릴레이는 작은 Cloudflare Worker입니다. ID를 통해 세션을 매칭하고 바이트를 전달합니다. 릴레이는 절대 키를 보유하지 않으며, 전달 이외의 목적으로 메시지를 버퍼링하지 않고, 콘텐츠를 검사하지도 않습니다. 만약 이조차 신뢰할 수 없다면, --no-relay --no-tunnel을 통해 제3자의 개입이 전혀 없는 LAN 전용 모드로 전환할 수 있습니다.
암호학 (Cryptography): 왜 TLS가 아닌 NaCl인가
TLS는 릴레이에서 종료됩니다. 설령 제가 직접 릴레이를 운영하고 인증서 핀닝 (certificate pinning)을 수행하더라도, 릴레이 서버가 해킹당하면 누구나 평문 (plaintext)을 볼 수 있습니다. 저는 오직 두 엔드포인트(endpoints)만이 키를 보유하는 암호화 방식이 필요했습니다.
저는 전체 세션을 위해 NaCl (libsodium의 Python 바인딩인 PyNaCl을 통해)을 선택했습니다:
-
키 교환 (Key exchange): 양측 모두 연결 시점에 일시적인 (ephemeral) X25519 키 쌍을 생성합니다. 공개 키는 QR 코드 스캔 과정에서 교환됩니다 (QR 코드는 부트 토큰과 함께 서버의 공개 키를 인코딩합니다). 브라우저는 자체적인 키 쌍을 생성하고 자신의 공개 키를 다시 전송합니다.
-
인증된 암호화 (Authenticated encryption): 모든 WebSocket 프레임은
crypto_box(XSalsa20-Poly1305)로 암호화됩니다. 이를 통해 인증된 암호화 (authenticated encryption)를 제공하며, 단순히 도청을 방지할 뿐만 아니라 데이터 변조도 감지할 수 있습니다. -
전방 비밀성 (Forward secrecy): 키는 일시적이며 디스크에 절대 저장되지 않습니다. 세션이 종료되면 키는 가비지 컬렉션 (garbage-collected)됩니다. 따라서 미래의 세션이 침해되더라도 과거의 세션에 대한 정보는 드러나지 않습니다.
구현 코드는 약 120줄의 Python으로 이루어져 있습니다. 커스텀 암호화 기술을 사용하지 않았으며, 표준적인 방식으로 조합된 NaCl 프리미티브 (primitives)만을 사용했습니다.
제가 특히 만족스러워하는 설계 선택 중 하나는 QR 코드 하나로 키 교환에 필요한 모든 것을 처리한다는 점입니다. 주고받는 과정이나 "이 코드를 휴대폰에 입력하세요"와 같은 절차가 필요 없습니다. 스캔하고, 연결하면, 암호화됩니다. 전체 핸드셰이크 (handshake)는 1초 미만 내에 완료됩니다.
터미널: WebSocket을 통한 PTY
CorvusTunnel의 핵심은 에이전트 프로세스를 실행하고 그 출력을 WebSocket을 통해 브라우저의 xterm.js 인스턴스로 스트리밍하는 가상 터미널 (PTY, pseudo-terminal)입니다.
서버 측에서는 실행기 (executor)가 Python의 pty.openpty()를 사용하여 PTY 내에서 에이전트 (claude, codex 또는 agy)를 생성합니다. 에이전트가 stdout에 쓰는 모든 바이트는 암호화 계층을 거쳐 암호화된 WebSocket 프레임 형태로 브라우저에 도착합니다. 브라우저는 이를 복호화하여 xterm.js에 전달하며, xterm.js는 이를 색상, 커서 이동, 대체 화면 버퍼 (alternate screen buffer) 등 실제 터미널과 동일하게 렌더링합니다.
입력 방식도 역방향으로 동일하게 작동합니다. 휴대폰에서 키를 누르면 암호화되어 릴레이 (relay)로 전송되고, 사용자의 머신으로 전달된 후, 복호화되어 PTY의 stdin에 기록됩니다. 에이전트는 이를 일반적인 키보드 입력으로 인식합니다.
이는 CorvusTunnel이 에이전트의 프로토콜을 이해할 필요가 없음을 의미합니다. 프롬프트(prompts)를 파싱하거나 승인 흐름(approval flows)을 가로채지 않습니다. 그저 터미널(terminal)일 뿐입니다. 에이전트가 로컬에서 보여주는 것은 무엇이든 원격에서도 볼 수 있습니다. 이것이 여러 에이전트를 지원하는 것이 사소한 작업이었던 이유입니다. 그들은 모두 그저 터미널에서 실행될 뿐이기 때문입니다.
두 개의 포트에서 실행되는 FastAPI
서버는 두 개의 포트에서 두 개의 FastAPI 애플리케이션을 실행합니다:
- 8000번 포트 (공용): 웹 UI를 제공하고, WebSocket 연결을 처리하며, 브라우저가 통신하는 API를 노출합니다. 이 포트는 릴레이(relay)에 연결됩니다.
- 8001번 포트 (내부, localhost 전용): 상태 확인(health checks), 세션 관리 및 감사 로그(audit log) 액세스를 위한 관리자 엔드포인트(admin endpoints)입니다. 이 포트는 절대 머신 외부로 나가지 않습니다.
이러한 분리는 의도적인 것이었습니다. 8000번 포트는 (릴레이를 통해) 인터넷에 노출되며 속도 제한(rate limiting), IP 차단, 바디 크기 제한(body-size caps), 보안 헤더, CORS 정책과 같은 전체 보안 스택을 적용받습니다. 8001번 포트는 127.0.0.1에 바인딩되어 호출자를 암묵적으로 신뢰합니다.
전체 인증 흐름으로서의 QR 코드
저는 휴대폰 측에서 설정이 전혀 필요 없는 방식을 원했습니다. 설치할 앱도, 붙여넣을 토큰도, 입력할 URL도 필요 없습니다. 그저 스캔하기만 하면 됩니다.
QR 코드는 일회용 부트 토큰(boot token), 서버의 휘발성(ephemeral) X25519 공개 키, 그리고 릴레이 세션 ID를 포함하는 URL을 인코딩합니다. 브라우저가 이 URL을 열면, 부트 토큰을 세션 토큰(session token)으로 교환하고(부트 토큰은 즉시 무효화됨), 자체 키 쌍(keypair)을 생성하며, 공유 비밀(shared secret)을 유도한 후 암호화된 WebSocket 연결을 엽니다.
부트 토큰은 60초의 TTL(Time To Live)을 가집니다. 1분 이내에 아무도 QR 코드를 스캔하지 않으면 서버는 종료되고 토큰은 만료됩니다. 오래된 링크가 남아있지 않습니다.
프론트엔드: React가 아닌 Svelte
웹 UI는 Svelte와 Vite로 구축되었습니다. 두 가지 이유가 있습니다:
- 번들 크기 (Bundle size). 프론트엔드 전체가 gzipped 기준 약 180KB로 컴파일됩니다. 셀룰러 연결을 사용하는 휴대폰 환경에서는 이 점이 중요합니다. React를 사용했다면 런타임(runtime)만으로도 40KB 이상이 추가되었을 것입니다.
- PWA 성능. CorvusTunnel은 PWA(Progressive Web App)로 설치됩니다. Svelte의 컴파일된 결과물은 시작 시 파싱해야 할 JavaScript 양이 적어, 중급 사양의 휴대폰에서도 "앱"이 네이티브처럼 빠르게 느껴지게 합니다.
xterm.js 위에 에이전트가 보여주는 내용에 맞춰 변화하는 퀵 액션 칩(Quick-action chips, "승인(Approve)", "거절(Reject)", "계속(Continue)"와 같은 컨텍스트 인식 버튼)과 자주 사용하는 명령어를 위한 고정 즐겨찾기 바(Pinned favorites bar)를 추가했습니다.
보안 강화 (Hardening): 릴레이가 적대적이라고 가정하기
제가 직접 릴레이를 운영함에도 불구하고, CorvusTunnel은 릴레이가 적대적인 환경(adversarial)인 것처럼 설계했습니다.
- 종단간 암호화 (E2E encryption): 릴레이가 침해되더라도 트래픽을 읽을 수 없습니다.
- IP 바인딩 세션 토큰 (IP-bound session tokens): 세션 하이재킹(session hijacking)을 방지합니다.
- 일회용 WebSocket 티켓 (One-time WebSocket tickets): 30초의 TTL(Time-To-Live)을 적용하여 재전송 공격(replay attacks)을 방지합니다.
- 자동 IP 차단 (Automatic IP bans): 인증 실패가 반복되면 자동으로 IP를 차단합니다.
- 명시적인 클라이언트 IP 신뢰 체인 (Explicit client-IP trust chain):
X-Forwarded-For헤더는TRUSTED_PROXIES로부터 온 경우에만 신뢰합니다. - 추가 전용 감사 로그 (Append-only audit logging): 완전한 포렌식 추적을 위해 JSONL 형식으로 기록합니다.
배운 점
추상화 수준을 낮게 유지하세요. 저의 첫 번째 프로토타입은 에이전트의 출력을 파싱하여 승인을 위한 커스텀 UI를 보여주려 했습니다. 이는 취약하고 에이전트 종속적이었으며, Claude Code가 출력 형식을 바꿀 때마다 작동이 중단되었습니다. 터미널 추상화는 투박하지만 범용적입니다. 또한 이는 멀티 에이전트 지원을 매우 쉽게 만들어 주었습니다. 세 가지 서로 다른 에이전트 프로토콜을 역공학(reverse-engineer)할 필요가 없었기 때문입니다. 그것들은 모두 터미널에서 실행됩니다. 그것으로 끝입니다.
--dangerously-skip-permissions가 정답은 아닙니다. 에이전트가 막히지 않도록 모든 것을 자동으로 승인하고 싶은 유혹이 생길 수 있습니다. 하지만 설령 그런 위험을 감수하더라도, 에이전트가 작업을 마쳤을 때 다음 작업을 전달해 주어야 합니다. 진짜 문제는 권한(permissions)이 아니라 존재감(presence)입니다.
QR 코드는 인증 (auth)에 있어 과소평가되어 있습니다. QR 흐름은 OAuth보다 빠르고, 매직 링크 (magic links)보다 간단하며, 오프라인에서도 작동합니다. 전체 키 교환 (key exchange)이 페이로드 (payload) 내에서 이루어집니다.
NaCl은 사용하기 매우 즐겁습니다. 암호 스위트 협상 (cipher suite negotiation), 모드 선택 (mode selection), IV 관리 (IV management)가 필요 없습니다. 하나의 함수는 암호화하고, 하나의 함수는 복호화합니다. 만약 TLS가 귀하의 위협 모델 (threat model)을 커버하지 못한다면, NaCl이 정답입니다.
사용해 보기
→ 웹사이트: corvustunnel.com
→ 소스 코드: github.com/maliozturk/CorvusTunnel
→ 비디오 가이드 (45초): YouTube에서 시청하기
이 프로젝트가 귀하의 문제를 해결해 주었다면, GitHub의 ⭐(Star)는 다른 사람들이 이 프로젝트를 찾는 데 큰 도움이 됩니다. 이슈 (Issues)와 PR (Pull Requests)은 언제나 환영합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기