"이 파일 코덱스로 보내줘."
원했던 사용 경험은 이 한마디가 전부였습니다. 한쪽 컴퓨터에서 작업하는 Codex와 다른 노드에서 돌아가는 Hermes가 파일을 주고받게 만들고 싶었습니다. 사람이 노드 이름이나 Tailscale 명령어를 외우지 않아도, 평소 말하듯 지시하면 알아서 보내고 받는 흐름입니다.
처음에는 tailscale file cp를 자연어 명령에 연결하면 끝날 줄 알았습니다. 실제로 파일을 보내는 것 자체는 어렵지 않았습니다. 까다로운 부분은 그다음이었습니다.
지금 받은 파일이 방금 보낸 파일인가?
수신함에 예전 파일이 남아 있다면 어떻게 구분할까?
전송 명령이 성공하면 상대가 확인한 것으로 봐도 될까?
ZIP 안의 파일이 보내기 전과 같은지 어떻게 알까?
파일이 여러 개거나, 여러 전송 건이 한꺼번에 도착하면 어떻게 나눌까?
대화를 시작할 때는 파일 전송 기능을 만들고 있었습니다. 끝날 때는 작은 전달 프로토콜 하나가 생겼습니다.
등장인물은 셋인데 전달 규칙은 없었습니다
이 작업에는 세 주체가 있었습니다.
사용자는 자연어로 파일 전달을 지시합니다.
Codex는 한 노드에서 파일을 만들고 보냅니다.
Hermes는 다른 노드에서 파일을 받고 검증하거나, 반대 방향으로 파일을 보냅니다.
원하는 명령은 양쪽에서 대칭적이었습니다.
이 파일 코덱스로 보내줘.
코덱스에서 온 파일 확인해줘.Codex 쪽에서도 같은 방식으로 Hermes를 부르면 됩니다. 단순한 명령이지만 내부에서는 누가 보내고 누가 받는지, 어느 전송 건인지, 어떤 파일이 포함됐는지를 양쪽이 같은 기준으로 해석해야 했습니다.
초기 아이디어는 한 방향으로 파일을 보내는 기능이었습니다. 대화를 이어가면서 단방향 기능 두 개보다 양쪽이 공유하는 규약 하나가 낫다는 결론이 나왔습니다.
"최신 파일"이라는 말부터 의심했습니다
수신 흐름을 설계하면서 처음 떠올린 방법은 간단했습니다. Taildrop 수신함의 파일을 임시 디렉터리에 꺼낸 뒤, 그 폴더의 파일을 열면 된다고 생각했습니다.
사용자가 바로 허점을 짚었습니다.
그 파일이 최신 파일이 아니면?
맞는 질문이었습니다. tailscale file get은 수신함에서 대기 중인 항목을 지정한 디렉터리로 옮깁니다. 새 임시 디렉터리를 쓴다고 해서 그 안의 파일이 모두 "방금 온 최신 파일"이 되는 것은 아닙니다. 예전에 도착했지만 아직 꺼내지 않은 파일도 함께 이동할 수 있습니다. 수정 시각이나 파일명만 보고 최신이라고 단정하는 것도 위험합니다.
임시 디렉터리는 기존 작업 파일과 섞이지 않게 해줄 뿐, 최신성을 증명하지 않습니다.
그래서 접근을 바꿨습니다. 파일 하나를 최신이라고 추측하는 대신 전송할 때마다 고유한 전송 ID를 만들고, 그 전송에 속한 파일을 ZIP 하나에 묶기로 했습니다. 수신할 때는 ZIP마다 별도의 전송 건으로 검증하고 보존합니다.
ZIP은 택배 상자, manifest는 물품 명세서
전송 규칙은 택배에 비유하면 이해하기 쉽습니다.
ZIP은 한 번의 전송을 담는 택배 상자입니다.
manifest.json은 상자 안의 물품 명세서입니다.SHA-256은 각 파일의 지문입니다.
sender와 recipient는 보내는 쪽과 받는 쪽입니다.
transfer ID는 전송 건을 구분하는 송장번호입니다.
파일이 하나여도 ZIP 하나를 만듭니다. 여러 파일을 보내면 같은 ZIP의 payload/ 아래에 넣습니다. ZIP 최상위에는 manifest.json과 명세서에 선언된 payload만 허용합니다.
형식은 대략 다음과 같습니다.
codex-to-hermes--<transfer-id>.zip
├── manifest.json
└── payload/
├── file-a.md
└── file-b.txt이렇게 하면 파일이 여러 개여도 전송 한 번의 경계가 분명합니다. 여러 ZIP이 한꺼번에 도착해도 서로 합치지 않고 각각 독립된 전송 건으로 처리할 수 있습니다.
"보냈다"와 "확인했다" 사이에는 여러 단계가 있었습니다
Tailscale 파일 전송 명령이 성공하면 파일은 상대 노드의 Taildrop에 인계됩니다. 하지만 상대 에이전트가 그 파일을 받거나 열어봤다는 뜻은 아닙니다.
실제 상태는 더 잘게 나뉩니다.
전송 명령 성공
→ 상대 Taildrop에 인계
→ 상대가 수신
→ ZIP 계약 검증
→ 안전한 압축 해제
→ 내용 확인그래서 전송 결과에는 handedOff라는 표현을 사용했습니다. handedOff: true는 상대 Taildrop까지 넘겼다는 뜻입니다. 상대가 내용을 읽었다고 보고하지 않습니다.
이 구분은 사소해 보이지만 중요했습니다. 에이전트끼리 작업을 이어갈 때 "보냈다"를 "처리 완료"로 오해하면 다음 단계가 빠집니다. 파일이 전송됐는지, 수신됐는지, 검증됐는지, 실제로 읽었는지를 각각 따로 말해야 했습니다.
수신 경로에 대한 가정도 한 번 틀렸습니다
Codex 쪽 테스트에서는 Taildrop CLI 대기함이 비어 있어 처음에 파일이 도착하지 않은 것으로 판단한 일이 있었습니다. 다시 확인해보니 Windows의 Tailscale이 파일을 기본 Downloads 폴더에 이미 저장한 상태였습니다.
파일이 없었던 것이 아닙니다. 확인 범위를 CLI 대기함으로만 좁게 잡은 것이 문제였습니다.
이 실패 이후 Codex 쪽 수신 흐름은 Taildrop 대기함뿐 아니라 운영체제가 자동 저장한 규약 이름의 ZIP도 확인하도록 보완됐습니다. 반면 Hermes가 동작하는 Linux/WSL 쪽에서는 tailscale file get으로 대기 파일을 새 staging에 가져오는 흐름을 사용합니다.
같은 Tailscale을 써도 운영체제와 클라이언트 동작에 따라 수신 경로가 달라질 수 있었습니다. 문서만 읽고 만든 가정은 실제 왕복 테스트에서 깨졌습니다.
받은 ZIP은 열기 전에 검증했습니다
수신 파일은 곧바로 풀거나 실행하지 않았습니다. 먼저 다음 항목을 확인했습니다.
프로토콜과 스키마 버전이 맞는가
ZIP 파일명과 transfer ID가 일치하는가
송신자와 수신자의 역할 및 노드 방향이 맞는가
manifest.json이 정확히 하나만 있는가manifest에 선언된 payload만 들어 있는가
실제 파일 크기와 SHA-256이 명세와 일치하는가
절대 경로,
.., 역슬래시 같은 위험한 경로가 없는가암호화된 member나 심볼릭 링크가 없는가
대소문자를 무시했을 때 중복되는 파일명이 없는가
기존 전송 폴더나 파일을 덮어쓰지 않는가
검증에 실패한 항목은 풀지 않고 quarantine으로 보냅니다. 정상 ZIP은 transfer ID별 디렉터리에 다음과 같이 보존합니다.
<transfer-id>/
├── envelope.zip
├── manifest.json
├── payload/
└── receipt.jsonreceipt.json에는 검증 방향, 전송 ID, ZIP 해시, 수신 시각을 기록합니다. 나중에 "어떤 ZIP을 어떤 기준으로 확인했는가"를 다시 추적할 수 있습니다.
그래도 발신자 신원까지 증명하는 것은 아닙니다
SHA-256과 manifest는 파일 무결성과 전송 계약을 확인하는 데 유용합니다. 하지만 이것만으로 실제 발신자의 신원을 암호학적으로 증명하지는 못합니다.
Taildrop 대기 메타데이터만으로는 송신 노드가 표시되지 않습니다. manifest에 적힌 "Codex가 보냈다"는 정보는 두 에이전트가 합의한 프로토콜상의 주장입니다. 누군가 동일한 형식의 manifest를 만들 가능성까지 막지는 못합니다.
그래서 receipt에는 다음 한계를 명시했습니다.현재 용도는 개인 Tailnet 안에서 두 에이전트가 파일을 주고받는 것입니다. 이 범위에서는 충분합니다. 다만 신뢰할 수 없는 여러 사용자가 참여하는 환경이라면 서명이나 별도의 인증 계층이 필요합니다.
스킬 생성 설명서를 그 스킬로 전달했습니다
이번 작업에서 가장 재미있었던 장면은 프로토콜이 자기 자신을 만드는 데 쓰였다는 점입니다.
Codex와 Hermes용 스킬 생성 설명서를 작성했습니다.
설명서와 테스트 메모를 ZIP 하나에 담았습니다.
Codex가 새 전달 규칙으로 Hermes에 보냈습니다.
Hermes가 ZIP의 방향, 파일 수, 크기, SHA-256을 검증했습니다.
설명서를 읽고 Hermes 쪽 스킬을 실제로 구현했습니다.
Python 컴파일, 구조 검사, 단위 테스트를 실행했습니다.
Hermes가 테스트 메모를 같은 규약으로 Codex에 다시 보냈습니다.
즉 파일 전달 스킬의 설계 문서가 그 파일 전달 규칙을 타고 반대편 에이전트에 도착했고, 도착한 문서로 역방향 구현이 만들어졌습니다.
Hermes 쪽 구현은 send, receive, verify 세 명령을 제공합니다. 테스트에서는 다음 항목을 실제로 확인했습니다.
파일 두 개가 ZIP 하나에 들어가는지
반대쪽 설정에서 incoming 검증과 안전한 압축 해제가 되는지
manifest에 없는 파일을 추가하면 거부하는지
payload 내용을 변조하면 거부하는지
대소문자만 다른 중복 basename을 거부하는지
실제 Codex 테스트 ZIP이 계약을 통과하는지
Python 컴파일과 스킬 구조 검사를 통과했고, 단위 테스트 5개도 모두 성공했습니다. 실제로 받은 ZIP 안의 두 payload는 manifest에 기록된 크기와 SHA-256과 정확히 일치했습니다.
파일에서 이미지까지, 실제 작업으로 이어졌습니다
프로토콜을 만든 뒤에는 테스트 텍스트만 주고받지 않았습니다. Codex가 만든 이미지 네 장을 ZIP 하나로 받아 검증했고, Hermes가 이를 Telegram 작업방으로 전달했습니다. 이어서 Codex가 작성한 사례글 구성안도 같은 방식으로 도착했습니다.
이 글 역시 그 구성안을 참고해 작성했습니다.
여기서 한 가지 차이도 확인했습니다. PNG 파일을 Telegram의 photo 방식으로 보내면 파일을 변환하지 않고 업로드하더라도 Telegram이 표시·저장 과정에서 이미지를 압축할 수 있습니다. 바이트 단위 원본을 보존해야 한다면 document 방식으로 보내야 합니다. "원본 파일을 읽었다"와 "원본 바이트 그대로 다른 서비스에 전달했다"는 또 다른 상태였습니다.
만들기 전과 만든 후
만들기 전에는 사람이 노드 이름과 명령어를 기억해야 했습니다. 여러 파일이 섞일 수 있었고, 전송 명령 성공을 상대 확인으로 오해하기 쉬웠습니다. 도착한 ZIP도 단순히 파일이 존재한다는 정도만 확인할 수 있었습니다.
지금은 사용자가 "코덱스로 보내줘"라고 말하면 에이전트가 지정된 파일만 ZIP 하나로 묶고 manifest를 만듭니다. 자체 검증을 통과한 ZIP만 고정된 상대 노드로 보냅니다. 반대쪽에서는 각 ZIP을 별도 전송 건으로 검증하고, 실패한 파일은 격리하며, 정상 파일은 receipt와 함께 보존합니다.
사용자에게 보이는 명령은 짧아졌지만 내부 절차는 더 엄격해졌습니다.
이번 작업에서 남은 교훈
첫째, 에이전트끼리 협업시킬 때는 프롬프트만 맞추는 것으로 부족했습니다. 두 에이전트가 같은 파일명 규칙, manifest 구조, 상태 표현을 공유해야 했습니다.
둘째, "보냈다"는 완료 상태가 아닙니다. 인계, 수신, 검증, 압축 해제, 내용 확인을 구분해야 실제 작업 상태를 정확하게 말할 수 있습니다.
셋째, 임시 디렉터리는 격리를 제공하지만 최신성을 증명하지 않습니다. 최신 파일을 추측하는 대신 고유한 전송 ID로 작업 단위를 만들어야 했습니다.
넷째, 자연어 인터페이스는 단순하게 두고 내부 검증은 엄격하게 만드는 편이 좋았습니다. 사용자는 "코덱스로 보내줘"라고만 말하지만, 시스템은 파일 종류와 경로, ZIP 구조, 크기, 해시, 송수신 방향을 확인합니다.
마지막으로 실제 왕복 테스트가 설계를 완성했습니다. 수신 경로에 대한 잘못된 가정, OS 호스트명과 Tailscale 노드명의 차이, Telegram 이미지 전송 시 원본 보존 문제는 문서만 보고는 놓치기 쉬웠습니다. 파일을 직접 보내고 받고 다시 돌려보냈기 때문에 발견할 수 있었습니다.
처음에는 파일 하나를 편하게 보내고 싶었습니다. 결국 만든 것은 두 AI가 "무엇을 주고받았는지" 같은 방식으로 설명할 수 있게 하는 작은 계약이었습니다.