와이어 프로토콜 v1.1
기기에 설치된 에이전트와 relay-service 사이의 WebSocket 규약. 이 디렉토리가 기준이다.
구현은 네 곳에 있다 — relay-service(TypeScript), linux-agent·mac-agent(Rust), chrome-agent(TypeScript), android-agent(Java).
프로토콜을 바꾸면 이 디렉토리 → relay-service → 에이전트 순서로 고치고, vectors.json 을 각 구현의 테스트가 다시 통과해야 한다.
endue-ai 의 기존 규약(agents shared/protocol.ts, WorkspaceSocketDO)과 같다. 다른 점은 맨 아래 원본과 다른 점.
v1.1 은 v1 에 더하기만 했다 (2026-10-04, endue OS v0.2.0). v1 에이전트는 그대로 붙고, v1.1 의 새 프레임은 규칙 2 에 따라 상대가 모르면 버린다.
v1.1 에서 생긴 것은 v1.1 에서 더한 것 한 곳에 모았다.
v1.2 는 접속 방법만 바꿨다 (2026-10-05) — 연결 토큰을 URL 쿼리가 아니라 Sec-WebSocket-Protocol 헤더로 보내고, 접속 때 401 이면 멈춘다. 프레임은 v1.1 그대로다. v1.2 에서 바꾼 것. 에이전트가 붙을 때 쓰는 도구의 입출력은 platform-tools.md.
GET wss://{host}/api/v1/relay/socketUpgrade: websocketSec-WebSocket-Protocol: endue-os.v1, endue-os.token.{연결 토큰}- 에이전트가 먼저 접속한다 (아웃바운드). 기기는 공인 주소가 필요 없다.
- 토큰은
Sec-WebSocket-Protocol에 싣는다 (v1.2). 에이전트는endue-os.v1과endue-os.token.{토큰}두 값을 보내고, 릴레이는 101 에Sec-WebSocket-Protocol: endue-os.v1을 돌려준다 (토큰 값은 돌려주지 않는다). URL 은 프록시·접근 로그·호출 로그에 남는다 — 쿼리에 실린 토큰은 재접속할 때마다 기록된다. 브라우저(chrome-agent)는 WebSocket 에 임의 헤더를 붙일 수 없어서 하위 프로토콜을 쓴다. - 토큰은 불투명한 문자열이다 (v0.2.0 형식
osat_{agent_id}_{hex 64}를 에이전트가 해석하지 않는다). 다만 연결 토큰은 영문자·숫자·_로만 이루어진다고 보장한다 — 그래서 그대로 하위 프로토콜 값이 된다. - 에이전트는 토큰을 로그에 남기지 않는다.
- v1·v1.1 의
?token={URL 인코딩한 토큰}도 계속 받는다 (옛 에이전트). 둘 다 있으면 헤더가 이긴다. v1.2 에이전트는 쿼리를 쓰지 않는다. - TLS 필수.
ws://는 loopback·로컬 컨테이너 개발용으로만. - 에이전트는 자기 종류(kind)를 말하지 않는다. 토큰이 에이전트·리소스·Station·kind 를 결정한다.
| 응답 | 뜻 | 에이전트는 |
|---|---|---|
| 101 | 연결됨 | — |
| 426 | Upgrade: websocket 이 없다 |
고칠 때까지 멈춘다 |
| 401 | 토큰이 없거나, 모르는 토큰이거나, 폐기됐다 | 재접속하지 않는다 (v1.2, 규칙 6) |
| 503 | 토큰을 확인할 수 없다 | 백오프로 재시도 (규칙 7) |
| 그 밖 · 연결 실패 | 네트워크·프록시 | 백오프로 재시도 |
브라우저 WebSocket 은 거절 응답의 상태 코드를 알려 주지 않는다 (close 1006 만 온다). chrome-agent 는 같은 토큰으로 연달아 open 전에 실패하면 HTTPS 로 한 번 확인한다 — chrome-agent.
프레임
섹션 제목: “프레임”텍스트 프레임 하나에 JSON 객체 하나. type 으로 구분한다. 스키마는 spec/protocol/schema/.
에이전트 → 릴레이
섹션 제목: “에이전트 → 릴레이”| type | 필수 | 선택 | 언제 |
|---|---|---|---|
ready |
version, tools |
os, arch, capabilities, session_epoch, catalog_hash·info_hash(v1.1) |
접속 직후 한 번 |
tool_response |
id, status, output, error, duration_ms |
error_code |
tool_request 마다 한 번 |
tool_ack (v1.1) |
id |
— | tool_request 를 받자마자 (capabilities 에 ack) |
tool_progress (v1.1) |
id, seq, data |
— | 실행 중 진행 상황 (capabilities 에 progress) |
ping |
— | — | 생존 확인 (주기는 에이전트가 정한다. 180초보다 짧게) |
pong |
— | — | 릴레이의 ping 에 대한 답 |
plan_update |
id, approach, domains, status, created_at |
— | chrome-agent 만. 릴레이는 받아서 버린다 |
릴레이 → 에이전트
섹션 제목: “릴레이 → 에이전트”| type | 필수 | 선택 | 언제 |
|---|---|---|---|
tool_request |
id, command, input |
timeout_ms |
도구 실행 요청 |
cancel_request |
id |
— | 진행 중인 요청 취소 |
relay_config (v1.1) |
max_inline_bytes, blob_upload_url, max_concurrency |
— | ready 를 받은 직후 한 번 |
ping |
— | — | v0.1.0 릴레이만 (알람 때). v0.2.0 릴레이는 주기적으로 보내지 않는다 |
pong |
— | — | 에이전트의 ping 에 대한 답 |
id— 요청과 응답을 짝짓는 값. 1~200자. 릴레이가 스스로 보내는 요청은sys:로 시작한다.command—^[a-z_]{1,64}$.input— JSON 객체. 에이전트가 자기 스키마로 검증한다.timeout_ms— 1~120000. 없으면 에이전트 기본값(30000). 릴레이는 늘 싣는다.status—ok·error·timeout.ok는 도구가 처리됐다는 뜻이지 작업 목표를 이뤘다는 뜻이 아니다.output— 객체 또는null.error— 문자열 또는null.duration_ms— 0 이상 정수.error_code— 기계가 읽는 오류 이름. v1.1 에이전트는 오류면 늘 보낸다 (chrome·android 는 v1 에서output.error_code에 넣었다). 같은id에 다른 입력이면request_id_conflict.
- ping 은 바이트가 정확해야 한다. 에이전트가 보내는 ping 은 정확히
{"type":"ping"}이다 (공백·다른 필드 없음). 릴레이는 이 바이트열에 런타임 자동응답으로{"type":"pong"}을 돌려준다 — Durable Object 가 깨어나지 않는다. 필드가 더 붙으면 답은 같지만 DO 가 매번 깨어난다. v0.2.0 릴레이에서 에이전트가 살아 있다는 근거는 이 ping 뿐이다 (아래 생존 확인). - 모르는 것은 버린다. 양쪽 모두 모르는
type, JSON 이 아닌 프레임, 바이너리 프레임을 받으면 연결을 끊지 않고 버린다. 모르는 필드는 무시한다. - 짝은
id로만 맞춘다. 응답 순서는 요청 순서와 달라도 된다. 재접속한 소켓으로 온 응답도 유효하다. - 같은
id는 한 번만 실행한다. 같은id·같은command·같은input을 다시 받으면 에이전트는 다시 실행하지 않고 같은 결과를 돌려준다. 같은id에 다른 입력이면status: "error"와error_code: "request_id_conflict"로 답한다. 비교에timeout_ms는 넣지 않는다 (v1.1). (보관 범위는 에이전트마다 다르다 — 각 에이전트 문서 참조. 무한한 exactly-once 가 아니다. 적어도 같은session_epoch동안은 기억해야 한다 — 릴레이가 재접속 뒤 같은 프레임을 다시 보내는 근거다.) - 취소는 되돌리기가 아니다.
cancel_request뒤에도 이미 일어난 효과는 남는다. 에이전트는 취소된 요청에도tool_response를 보낸다. - 4001 이면 재접속하지 않는다 (v1.1). 접속 때 401 이어도 재접속하지 않는다 (v1.2). 토큰이 폐기·회전됐다는 뜻이고, 다시 붙어도 401 이다. 새 토큰을 받을 때까지 멈춘다. v1 에이전트는 이것을 지키지 않는다 — 폐기된 토큰으로 영원히 재시도한다. 릴레이는 거절한 토큰 해시를 1시간 기억해서 그런 재시도가 DB 를 치지 않게 막지만, 재시도마다 요청 하나는 남는다.
- 재접속은 지터를 넣은 지수 백오프 (v1.1). 1초에서 시작해 두 배씩, 상한은 에이전트가 정한다(60초 권장). 매번 0~50% 를 무작위로 더한다. 릴레이를 배포하면 모든 에이전트가 한꺼번에 끊긴다 — 지터가 없으면 같은 순간에 몰려 돌아온다.
핸드셰이크
섹션 제목: “핸드셰이크”에이전트 릴레이 │── ready {version, tools, os, arch, session_epoch, catalog_hash?, info_hash?} ─▶│ │◀── relay_config {max_inline_bytes, blob_upload_url, max_concurrency} ──────────│ v1.1. v1 에이전트는 버린다 │◀── tool_request sys:describe:… describe_tools {} ──│ timeout_ms 10000 — catalog_hash 를 알면 생략 │── tool_response {output:{tools:[…]}} ─▶│ │◀── tool_request sys:info:… workspace_info {} ──────│ timeout_ms 10000 — info_hash 를 알면 생략 │── tool_response {output:{…}} ─────────▶│ └─ 바뀐 것이 있을 때만 플랫폼에 보고한다- 모든 에이전트는
describe_tools와workspace_info를 구현한다 (platform-tools.md). describe_tools의output.tools[]원소:name,description,input_schema(JSON Schema),requires_confirmation(불리언).- 릴레이는
ready.tools를 쓰지 않는다. 카탈로그의 기준은describe_tools다. - 카탈로그가 비어 있으면 그 리소스는 온라인이 아니다.
catalog_hash·info_hash는 캐시 키일 뿐이다. 릴레이는 그 에이전트가 지난번에 보낸 해시와 같을 때만 요청을 생략하고, 저장할 때는 자기가 계산한 내용 해시를 쓴다 — 에이전트끼리 해시 계산 방식이 같을 필요는 없다.
생존 확인
섹션 제목: “생존 확인”v0.2.0 릴레이는 가만히 있는 에이전트에게 아무것도 하지 않는다 — 주기 알람·ping·플랫폼 보고가 없다 (설계 문서 D-30).
- 에이전트는 자기 주기로 정확한
{"type":"ping"}을 보낸다. 릴레이는 자동응답으로만 답한다. - 릴레이는 명령을 보내기 직전과 그 리소스를 조회할 때 마지막 자동응답 시각을 본다. ping 주기의 2.5배보다 오래됐으면 소켓을
4008 liveness check failed로 닫고 오프라인으로 친다. - ping 주기는
capabilities의ping_interval:{초}로 알린다. 없으면 kind 의 기본값 — linux·mac 60초, chrome·android 20초. - 그래서 ping 주기를 지키지 않는 에이전트는 명령을 받을 때 끊긴다. 주기는 중간 장비의 idle timeout 이 허락하는 한 길게 잡는다 (Chrome MV3 서비스 워커는 30초 안에 활동이 있어야 해서 20초).
v1.1 에서 더한 것
섹션 제목: “v1.1 에서 더한 것”| 무엇 | 방향 | 내용 | v1 상대방 |
|---|---|---|---|
ready.session_epoch 를 사실상 필수로 |
에이전트 → | 프로세스가 시작할 때마다 새 값 (UUID 권장) | 보내지 않으면 재접속 때 진행 중이던 명령이 unknown 이 된다 (안전 쪽) |
ready.capabilities 의 값 |
에이전트 → | concurrency:{N} 동시 실행 수 · ping_interval:{초} · blob_upload · progress · ack |
kind 별 기본값을 쓴다 |
ready.catalog_hash · ready.info_hash |
에이전트 → | 카탈로그·기기 정보의 버전 문자열. 릴레이가 아는 값이면 그 요청을 생략한다 | 릴레이가 매번 묻는다 |
relay_config |
→ 에이전트 | 블롭 업로드 주소(경로 — 소켓의 origin 에 붙인다), 인라인 상한, 릴레이가 지킬 동시 실행 상한 | 모르는 type 이라 버린다 |
tool_ack |
에이전트 → | 받았다 — 명령이 running 이 된다 |
릴레이가 버린다 |
tool_progress |
에이전트 → | 진행 상황. 구독자에게만 흐르고 저장하지 않는다. 요청당 초당 4개, data 64 KiB 까지 |
릴레이가 버린다 |
출력의 {이름}_blob |
에이전트 → | 큰 바이트를 HTTPS 로 올리고 출력에는 참조만 싣는다 (아래) | *_base64 인라인 (상한 안에서만) |
| 규칙 4·6·7 | 에이전트 | timeout_ms 를 지문에서 뺀다 · 4001 이면 멈춘다 · 지터 |
— |
v1.2 에서 바꾼 것
섹션 제목: “v1.2 에서 바꾼 것”| 무엇 | 방향 | 내용 | v1·v1.1 상대방 |
|---|---|---|---|
토큰을 Sec-WebSocket-Protocol 로 |
에이전트 → | endue-os.v1, endue-os.token.{토큰}. 릴레이는 endue-os.v1 을 고른다 |
릴레이는 ?token= 도 받는다. v1.2 에이전트가 v1.1 릴레이에 붙으면 101 에 하위 프로토콜이 없어 실패한다 — 릴레이를 먼저 올린다 |
| 접속 때 401 이면 멈춘다 | 에이전트 | 규칙 6 | 릴레이의 부정 캐시가 DB 를 막는다 |
| 블롭 업로드 | 에이전트 → | 그대로 Authorization: Bearer (원래 쿼리가 아니었다) |
— |
v1.2 에이전트는 ready.version 이 아니라 접속 방법으로만 다르다. 릴레이는 하위 프로토콜이 왔는지로 구별할 필요가 없다 (둘 다 받는다).
chrome-agent 는 401 을 어떻게 아나
섹션 제목: “chrome-agent 는 401 을 어떻게 아나”브라우저 WebSocket 은 핸드셰이크 실패의 HTTP 상태를 감춘다. chrome-agent 는 소켓이 open 전에 닫히는 일이 같은 토큰으로 연달아 일어나면(2번) 게이트웨이에 HTTPS 로 같은 경로를 한 번 묻는다
— GET https://{host}/api/v1/relay/socket 에 Authorization: Bearer {토큰} 을 싣고 Upgrade 는 싣지 않는다 (fetch 는 Sec-* 헤더를 붙일 수 없다). 릴레이는 토큰을 먼저 확인하므로 토큰이 틀리면 401, 맞으면 426 이다.
401 이면 멈추고 사용자에게 다시 연결하라고 알린다. 426·503·연결 실패면 백오프를 이어 간다.
릴레이가 토큰을 찾는 순서: Sec-WebSocket-Protocol → Authorization: Bearer → ?token=.
큰 바이트 — 블롭 업로드
섹션 제목: “큰 바이트 — 블롭 업로드”출력에 넣을 바이트가 relay_config.max_inline_bytes 보다 크면(또는 blob_upload 를 알리고 늘) HTTPS 로 올린다. WebSocket 프레임 상한(8 MiB)을 넘는 스크린샷이 버려져 명령이 timeout 으로 끝나던 문제를 없앤다.
PUT {업로드 주소}/{request_id}/{name}Authorization: Bearer {연결 토큰}Content-Type: image/png
<바이트>업로드 주소는 소켓과 같은 origin 이다. 연결 토큰을 싣고 보내므로 다른 곳으로 보내지 않는다.
relay_config.blob_upload_url |
업로드 주소 |
|---|---|
경로 /api/v1/relay/blobs — 릴레이가 보내는 형태 |
에이전트가 접속한 소켓 URL 의 origin + 그 경로. wss://dev.api.endueos.com/api/v1/relay/socket 로 붙었으면 https://dev.api.endueos.com/api/v1/relay/blobs, 로컬의 ws://127.0.0.1:18400/… 이면 http://127.0.0.1:18400/api/v1/relay/blobs |
| 절대 URL | origin(스킴·호스트·포트)이 소켓의 origin 과 같을 때만 쓴다 (ws↔http, wss↔https 로 맞춰 비교). 다르면 올리지 않는다 — 인라인 상한 안이면 *_base64 로 싣고, 아니면 status: "error", error_code: "blob_upload_failed" |
// 로 시작하거나 http(s) 가 아닌 것 |
스키마 위반 — 프레임 전체를 버린다 (규칙 2) |
릴레이가 경로로 보내는 이유: 릴레이는 게이트웨이 뒤에 있어 에이전트가 어떤 주소로 붙었는지 모른다 (공개 도메인, 로컬 포워더의 루프백 포트). 경로면 어디서 붙었든 맞는다. 서명된 R2 주소(presigned URL)처럼 다른 origin 으로 올리는 방식은 v1.1 에 없다.
| 항목 | 규칙 |
|---|---|
request_id |
그 바이트가 속한 tool_request.id. 이 에이전트의 진행 중인 명령이어야 한다 — 아니면 409 BLOB_NOT_ALLOWED |
name |
^[a-z0-9][a-z0-9._-]{0,63}$. 명령 하나 안에서 유일. 같은 이름으로 다시 올리면 덮어쓴다 |
| 크기 | 64 MiB 까지 (413 BLOB_TOO_LARGE) |
| 응답 | 201 {data: {name, mime_type, size, sha256}} |
| 출력 | 올린 뒤 tool_response.output 에 {이름}_blob: {name, mime_type, size, sha256} 를 싣는다. 예: image_blob 이 image_base64 를 대신한다 |
| 보관 | 24시간. 호출자는 GET /api/v1/commands/{command_id}/blobs/{name} 으로 받는다 |
순서가 중요하다 — 올리기가 끝난 뒤에 tool_response 를 보낸다. 릴레이는 응답을 받으면 블롭이 이미 있다고 믿는다.
닫힘 코드 (릴레이 → 에이전트)
섹션 제목: “닫힘 코드 (릴레이 → 에이전트)”| 코드 | 사유 | 뜻 |
|---|---|---|
| 1012 | superseded by new connection |
같은 에이전트의 새 접속이 이 소켓을 대체했다 |
| 4001 | agent revoked |
토큰이 폐기·회전됐다. 재접속하면 401. v1.1 에이전트는 재접속하지 않는다 |
| 4008 | liveness check failed |
지연 생존 확인에서 마지막 ping 이 너무 오래됐다 (v0.2.0). 평소처럼 재접속한다 |
| 항목 | 값 | 정하는 쪽 |
|---|---|---|
| 프레임 크기 (에이전트 → 릴레이) | 8 MiB (RELAY_MAX_FRAME_BYTES) |
릴레이. 넘으면 버린다 |
| 프레임 크기 (릴레이 → 에이전트) | 에이전트마다 (android 400,000자) | 에이전트 |
| 인라인 바이트 상한 | 256 KiB (relay_config.max_inline_bytes) |
릴레이 |
| 블롭 하나 | 64 MiB | 릴레이 |
| 동시 실행 (전달 창) | min(32, 에이전트의 concurrency) — 기본 linux·mac 8, android 1, chrome 1 |
릴레이 |
| 에이전트당 줄 | 100건 | 릴레이 |
timeout_ms |
1~120000 | 릴레이 |
| 취소 뒤 유예 | 2초 | 릴레이 |
| 끊김 유예 | 60초 — 이 안에 다시 붙고 해시가 같으면 아무것도 보고하지 않는다 | 릴레이 |
tool_progress |
요청당 초당 4개, data 64 KiB |
릴레이. 넘으면 버린다 |
spec/protocol/vectors.json — 방향별 유효·무효 프레임 (version: 1, minor: 1). npm run check:protocol 이 스키마와 대조한다.
각 구현은 이 파일로 자기 파서·직렬화기를 시험한다. 사본(platform·agents)은 슈퍼 레포의 scripts/check-contract-copies.sh --sync 로 맞춘다.
원본과 다른 점
섹션 제목: “원본과 다른 점”| 항목 | endue-ai WorkspaceSocketDO |
endue-os relay-service |
|---|---|---|
| 접속 경로 | /api/v1/ai/workspace-socket |
/api/v1/relay/socket |
| 토큰 prefix | wsat_ |
osat_ (에이전트는 토큰을 불투명한 문자열로 다룬다) |
| 4001 사유 | binding revoked |
agent revoked |
| 폐기 반영 | 다음 알람 (최대 180초) | 즉시 (통지는 Queue 로 재시도해 반드시 닿는다) |
| 생존 확인 | 180초 알람 + 보고 | 지연 확인 (명령 직전·조회 때). 주기 작업 없음 |
| 프레임 크기 검사 | 없음 | 8 MiB |
ready 에서 읽는 값 |
version, os |
version, os, arch, session_epoch, capabilities, catalog_hash, info_hash |
timeout_ms 상한 |
60000 | 120000 |
| 큰 바이트 | 프레임에 base64 | 블롭 업로드 (v1.1) |
ENDUE-1038 resource_usage
섹션 제목: “ENDUE-1038 resource_usage”고정 resource_usage 시스템 호출은 빈 입력만 받는다. relay의 sys: 요청이므로 command 원장과 명령 이벤트를 생성하지 않는다. CPU는 샘플 구간의 사용률(0..100), 메모리와 디스크는 byte이며 측정하지 못한 값은 null이다. source는 guest/container/provider, scope는 machine/workspace_container, observed_at_ms는 Unix 밀리초다. 서버는 30초 동안 새 요청을 합치고, 관측 후 90초가 지나면 stale로 표시한다. Chrome 확장만 설치한 기기는 unsupported=true와 null 값을 반환한다.
Storage v2’s separate tool, bounded steps, independent multibind capability and frozen canonical data are specified in os-interface.md. storage-vectors.json and storage-unicode.json are shared with platform/agents; the public 35-operation interface and exact ping bytes stay compatible.