콘텐츠로 이동

와이어 프로토콜 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/socket
Upgrade: websocket
Sec-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.
  1. ping 은 바이트가 정확해야 한다. 에이전트가 보내는 ping 은 정확히 {"type":"ping"} 이다 (공백·다른 필드 없음). 릴레이는 이 바이트열에 런타임 자동응답으로 {"type":"pong"} 을 돌려준다 — Durable Object 가 깨어나지 않는다. 필드가 더 붙으면 답은 같지만 DO 가 매번 깨어난다. v0.2.0 릴레이에서 에이전트가 살아 있다는 근거는 이 ping 뿐이다 (아래 생존 확인).
  2. 모르는 것은 버린다. 양쪽 모두 모르는 type, JSON 이 아닌 프레임, 바이너리 프레임을 받으면 연결을 끊지 않고 버린다. 모르는 필드는 무시한다.
  3. 짝은 id 로만 맞춘다. 응답 순서는 요청 순서와 달라도 된다. 재접속한 소켓으로 온 응답도 유효하다.
  4. 같은 id 는 한 번만 실행한다. 같은 id·같은 command·같은 input 을 다시 받으면 에이전트는 다시 실행하지 않고 같은 결과를 돌려준다. 같은 id 에 다른 입력이면 status: "error" 와 error_code: "request_id_conflict" 로 답한다. 비교에 timeout_ms 는 넣지 않는다 (v1.1). (보관 범위는 에이전트마다 다르다 — 각 에이전트 문서 참조. 무한한 exactly-once 가 아니다. 적어도 같은 session_epoch 동안은 기억해야 한다 — 릴레이가 재접속 뒤 같은 프레임을 다시 보내는 근거다.)
  5. 취소는 되돌리기가 아니다. cancel_request 뒤에도 이미 일어난 효과는 남는다. 에이전트는 취소된 요청에도 tool_response 를 보낸다.
  6. 4001 이면 재접속하지 않는다 (v1.1). 접속 때 401 이어도 재접속하지 않는다 (v1.2). 토큰이 폐기·회전됐다는 뜻이고, 다시 붙어도 401 이다. 새 토큰을 받을 때까지 멈춘다. v1 에이전트는 이것을 지키지 않는다 — 폐기된 토큰으로 영원히 재시도한다. 릴레이는 거절한 토큰 해시를 1시간 기억해서 그런 재시도가 DB 를 치지 않게 막지만, 재시도마다 요청 하나는 남는다.
  7. 재접속은 지터를 넣은 지수 백오프 (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 상대방
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·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 이 아니라 접속 방법으로만 다르다. 릴레이는 하위 프로토콜이 왔는지로 구별할 필요가 없다 (둘 다 받는다).

브라우저 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)

고정 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.