From dba30c00ca3a6d79af7ff24a88def90517ce6173 Mon Sep 17 00:00:00 2001 From: "openclaw-docs-i18n[bot]" Date: Thu, 16 Apr 2026 06:02:00 +0000 Subject: [PATCH] chore(i18n): refresh ko translations --- docs/ko/gateway/protocol.md | 429 ++++++++++-------- ...exec-duplicate-completion-investigation.md | 132 ++++++ 2 files changed, 366 insertions(+), 195 deletions(-) create mode 100644 docs/ko/refactor/async-exec-duplicate-completion-investigation.md diff --git a/docs/ko/gateway/protocol.md b/docs/ko/gateway/protocol.md index 225840257..31a318a40 100644 --- a/docs/ko/gateway/protocol.md +++ b/docs/ko/gateway/protocol.md @@ -6,22 +6,22 @@ read_when: summary: 'Gateway WebSocket 프로토콜: 핸드셰이크, 프레임, 버전 관리' title: Gateway 프로토콜 x-i18n: - generated_at: "2026-04-11T02:44:57Z" + generated_at: "2026-04-16T06:00:28Z" model: gpt-5.4 provider: openai - source_hash: 83c820c46d4803d571c770468fd6782619eaa1dca253e156e8087dec735c127f + source_hash: 683e61ebe993a2d739bc34860060b0e3eda36b5c57267a2bcc03d177ec612fb3 source_path: gateway/protocol.md workflow: 15 --- # Gateway 프로토콜 (WebSocket) -Gateway WS 프로토콜은 OpenClaw를 위한 **단일 제어 플레인 + 노드 전송 계층**입니다. 모든 클라이언트(CLI, 웹 UI, macOS 앱, iOS/Android 노드, 헤드리스 노드)는 WebSocket을 통해 연결하고 핸드셰이크 시점에 자신의 **역할**과 **범위**를 선언합니다. +Gateway WS 프로토콜은 OpenClaw의 **단일 제어 평면 + 노드 전송**입니다. 모든 클라이언트(CLI, 웹 UI, macOS 앱, iOS/Android 노드, 헤드리스 노드)는 WebSocket을 통해 연결되며 핸드셰이크 시점에 자신의 **역할** + **범위**를 선언합니다. -## 전송 계층 +## 전송 -- WebSocket, JSON 페이로드를 사용하는 텍스트 프레임 -- 첫 번째 프레임은 반드시 `connect` 요청이어야 합니다. +- WebSocket, JSON 페이로드를 사용하는 텍스트 프레임. +- 첫 번째 프레임은 **반드시** `connect` 요청이어야 합니다. ## 핸드셰이크(connect) @@ -77,11 +77,24 @@ Gateway → 클라이언트: "type": "res", "id": "…", "ok": true, - "payload": { "type": "hello-ok", "protocol": 3, "policy": { "tickIntervalMs": 15000 } } + "payload": { + "type": "hello-ok", + "protocol": 3, + "server": { "version": "…", "connId": "…" }, + "features": { "methods": ["…"], "events": ["…"] }, + "snapshot": { "…": "…" }, + "policy": { + "maxPayload": 26214400, + "maxBufferedBytes": 52428800, + "tickIntervalMs": 15000 + } + } } ``` -디바이스 토큰이 발급되면 `hello-ok`에는 다음도 포함됩니다: +`server`, `features`, `snapshot`, `policy`는 모두 스키마(`src/gateway/protocol/schema/frames.ts`)에서 필수입니다. `auth`와 `canvasHostUrl`은 선택 사항입니다. + +디바이스 토큰이 발급되면 `hello-ok`에는 다음도 포함됩니다. ```json { @@ -93,7 +106,7 @@ Gateway → 클라이언트: } ``` -신뢰된 부트스트랩 핸드오프 중에는 `hello-ok.auth`에 `deviceTokens` 내 추가적인 범위 제한 역할 항목이 포함될 수도 있습니다: +신뢰된 부트스트랩 핸드오프 중에는 `hello-ok.auth`에 추가적인 범위 제한 역할 항목이 `deviceTokens`에 포함될 수도 있습니다. ```json { @@ -112,9 +125,9 @@ Gateway → 클라이언트: } ``` -내장된 node/operator 부트스트랩 흐름에서는 기본 노드 토큰이 `scopes: []`로 유지되고, 핸드오프된 모든 operator 토큰은 부트스트랩 operator 허용 목록(`operator.approvals`, `operator.read`, `operator.talk.secrets`, `operator.write`)으로 제한됩니다. 부트스트랩 범위 검사는 계속해서 역할 접두사 기반으로 유지됩니다. operator 항목은 operator 요청만 충족하며, operator가 아닌 역할은 여전히 자신의 역할 접두사 아래 범위가 필요합니다. +내장된 node/operator 부트스트랩 흐름의 경우, 기본 node 토큰은 계속 `scopes: []`를 유지하며, 핸드오프된 모든 operator 토큰은 부트스트랩 operator 허용 목록(`operator.approvals`, `operator.read`, `operator.talk.secrets`, `operator.write`)으로 제한됩니다. 부트스트랩 범위 검사는 역할 접두사 기준을 유지합니다. operator 항목은 operator 요청만 충족하며, operator가 아닌 역할은 여전히 자신의 역할 접두사 아래 범위가 필요합니다. -### 노드 예시 +### Node 예시 ```json { @@ -161,8 +174,8 @@ Gateway → 클라이언트: ### 역할 -- `operator` = 제어 플레인 클라이언트(CLI/UI/자동화) -- `node` = 기능 호스트(camera/screen/canvas/system.run) +- `operator` = 제어 평면 클라이언트(CLI/UI/자동화). +- `node` = 기능 호스트(camera/screen/canvas/system.run). ### 범위(operator) @@ -175,305 +188,331 @@ Gateway → 클라이언트: - `operator.pairing` - `operator.talk.secrets` -`includeSecrets: true`가 있는 `talk.config`에는 `operator.talk.secrets`(또는 `operator.admin`)가 필요합니다. +`includeSecrets: true`가 있는 `talk.config`는 `operator.talk.secrets`(또는 `operator.admin`)가 필요합니다. -plugin 등록 Gateway RPC 메서드는 자체 operator 범위를 요구할 수 있지만, 예약된 핵심 관리자 접두사(`config.*`, `exec.approvals.*`, `wizard.*`, `update.*`)는 항상 `operator.admin`으로 해석됩니다. +Plugin에 등록된 gateway RPC 메서드는 자체 operator 범위를 요구할 수 있지만, 예약된 코어 admin 접두사(`config.*`, `exec.approvals.*`, `wizard.*`, `update.*`)는 항상 `operator.admin`으로 해석됩니다. -메서드 범위는 첫 번째 게이트일 뿐입니다. `chat.send`를 통해 도달하는 일부 슬래시 명령은 그 위에 더 엄격한 명령 수준 검사를 적용합니다. 예를 들어 영구적인 `/config set` 및 `/config unset` 쓰기 작업에는 `operator.admin`이 필요합니다. +메서드 범위는 첫 번째 게이트일 뿐입니다. `chat.send`를 통해 도달하는 일부 슬래시 명령은 그 위에 더 엄격한 명령 수준 검사를 적용합니다. 예를 들어, 영구적인 `/config set` 및 `/config unset` 쓰기에는 `operator.admin`이 필요합니다. -`node.pair.approve`도 기본 메서드 범위 외에 추가적인 승인 시점 범위 검사가 있습니다: +`node.pair.approve`에도 기본 메서드 범위 위에 추가 승인 시점 범위 검사가 있습니다. -- 명령 없는 요청: `operator.pairing` -- exec가 아닌 노드 명령이 포함된 요청: `operator.pairing` + `operator.write` -- `system.run`, `system.run.prepare`, 또는 `system.which`가 포함된 요청: +- 명령이 없는 요청: `operator.pairing` +- exec가 아닌 node 명령이 있는 요청: `operator.pairing` + `operator.write` +- `system.run`, `system.run.prepare`, `system.which`가 포함된 요청: `operator.pairing` + `operator.admin` ### caps/commands/permissions(node) -노드는 연결 시 기능 클레임을 선언합니다: +노드는 연결 시점에 기능 클레임을 선언합니다. -- `caps`: 상위 수준 기능 카테고리 -- `commands`: invoke용 명령 허용 목록 -- `permissions`: 세부 토글(예: `screen.record`, `camera.capture`) +- `caps`: 상위 수준 기능 카테고리. +- `commands`: invoke를 위한 명령 허용 목록. +- `permissions`: 세부 토글(예: `screen.record`, `camera.capture`). -Gateway는 이를 **클레임**으로 취급하고 서버 측 허용 목록을 적용합니다. +Gateway는 이를 **클레임**으로 취급하고 서버 측 허용 목록을 강제 적용합니다. ## 프레즌스 -- `system-presence`는 디바이스 정체성을 키로 하는 항목을 반환합니다. -- 프레즌스 항목에는 `deviceId`, `roles`, `scopes`가 포함되므로 UI는 디바이스가 **operator**와 **node**로 모두 연결되어 있더라도 디바이스당 한 행만 표시할 수 있습니다. +- `system-presence`는 디바이스 ID를 키로 하는 항목을 반환합니다. +- 프레즌스 항목에는 `deviceId`, `roles`, `scopes`가 포함되므로 UI는 동일한 디바이스가 **operator**와 **node**로 모두 연결되더라도 한 행으로 표시할 수 있습니다. ## 일반적인 RPC 메서드 계열 -이 페이지는 생성된 전체 덤프가 아니지만, 공개 WS 표면은 위의 핸드셰이크/인증 예시보다 더 넓습니다. 현재 Gateway가 노출하는 주요 메서드 계열은 다음과 같습니다. +이 페이지는 생성된 전체 덤프가 아니지만, 공개 WS 표면은 위의 핸드셰이크/인증 예시보다 더 넓습니다. 아래는 현재 Gateway가 노출하는 주요 메서드 계열입니다. -`hello-ok.features.methods`는 `src/gateway/server-methods-list.ts`와 로드된 plugin/channel 메서드 export를 바탕으로 구성된 보수적인 디스커버리 목록입니다. 이를 기능 디스커버리로 취급하고, `src/gateway/server-methods/*.ts`에 구현된 모든 호출 가능한 헬퍼의 생성된 덤프로 취급하지 마세요. +`hello-ok.features.methods`는 `src/gateway/server-methods-list.ts`와 로드된 plugin/channel 메서드 export를 기반으로 만들어진 보수적인 탐색 목록입니다. 이를 기능 탐색으로 취급하고, `src/gateway/server-methods/*.ts`에 구현된 호출 가능한 모든 헬퍼의 생성된 덤프로 취급하지 마세요. -### 시스템 및 정체성 +### 시스템 및 ID -- `health`는 캐시된 또는 새로 프로브된 Gateway 상태 스냅샷을 반환합니다. -- `status`는 `/status` 스타일의 Gateway 요약을 반환합니다. 민감한 필드는 관리자 범위가 있는 operator 클라이언트에게만 포함됩니다. -- `gateway.identity.get`은 릴레이 및 페어링 흐름에 사용되는 Gateway 디바이스 정체성을 반환합니다. -- `system-presence`는 연결된 operator/node 디바이스의 현재 프레즌스 스냅샷을 반환합니다. +- `health`는 캐시되었거나 새로 프로브한 gateway 상태 스냅샷을 반환합니다. +- `status`는 `/status` 스타일의 gateway 요약을 반환합니다. 민감한 필드는 admin 범위를 가진 operator 클라이언트에만 포함됩니다. +- `gateway.identity.get`은 릴레이 및 페어링 흐름에서 사용하는 gateway 디바이스 ID를 반환합니다. +- `system-presence`는 현재 연결된 operator/node 디바이스의 프레즌스 스냅샷을 반환합니다. - `system-event`는 시스템 이벤트를 추가하고 프레즌스 컨텍스트를 업데이트/브로드캐스트할 수 있습니다. -- `last-heartbeat`는 가장 최근에 저장된 heartbeat 이벤트를 반환합니다. -- `set-heartbeats`는 Gateway에서 heartbeat 처리를 전환합니다. +- `last-heartbeat`는 가장 최근에 저장된 Heartbeat 이벤트를 반환합니다. +- `set-heartbeats`는 gateway에서 Heartbeat 처리를 켜거나 끕니다. ### 모델 및 사용량 -- `models.list`는 런타임에서 허용된 모델 카탈로그를 반환합니다. -- `usage.status`는 provider 사용량 윈도우/잔여 할당량 요약을 반환합니다. +- `models.list`는 런타임에서 허용되는 모델 카탈로그를 반환합니다. +- `usage.status`는 제공자 사용량 기간/남은 할당량 요약을 반환합니다. - `usage.cost`는 날짜 범위에 대한 집계된 비용 사용량 요약을 반환합니다. -- `doctor.memory.status`는 활성 기본 에이전트 워크스페이스의 벡터 메모리/임베딩 준비 상태를 반환합니다. +- `doctor.memory.status`는 활성 기본 agent 워크스페이스에 대한 벡터 메모리 / 임베딩 준비 상태를 반환합니다. - `sessions.usage`는 세션별 사용량 요약을 반환합니다. -- `sessions.usage.timeseries`는 하나의 세션에 대한 시계열 사용량을 반환합니다. -- `sessions.usage.logs`는 하나의 세션에 대한 사용량 로그 항목을 반환합니다. +- `sessions.usage.timeseries`는 한 세션의 시계열 사용량을 반환합니다. +- `sessions.usage.logs`는 한 세션의 사용량 로그 항목을 반환합니다. ### 채널 및 로그인 헬퍼 -- `channels.status`는 내장 + 번들 채널/plugin 상태 요약을 반환합니다. -- `channels.logout`은 해당 채널이 로그아웃을 지원하는 경우 특정 채널/계정에서 로그아웃합니다. -- `web.login.start`는 현재 QR 지원 웹 채널 provider에 대한 QR/웹 로그인 흐름을 시작합니다. -- `web.login.wait`는 해당 QR/웹 로그인 흐름이 완료될 때까지 기다리고 성공 시 채널을 시작합니다. -- `push.test`는 등록된 iOS 노드에 테스트 APNs 푸시를 전송합니다. +- `channels.status`는 내장 + 번들 channel/plugin 상태 요약을 반환합니다. +- `channels.logout`은 해당 channel이 로그아웃을 지원하는 경우 특정 channel/account에서 로그아웃합니다. +- `web.login.start`는 현재 QR 사용 가능 웹 channel 제공자에 대해 QR/웹 로그인 흐름을 시작합니다. +- `web.login.wait`는 해당 QR/웹 로그인 흐름이 완료될 때까지 기다리고, 성공 시 channel을 시작합니다. +- `push.test`는 등록된 iOS node에 테스트 APNs 푸시를 전송합니다. - `voicewake.get`은 저장된 웨이크 워드 트리거를 반환합니다. - `voicewake.set`은 웨이크 워드 트리거를 업데이트하고 변경 사항을 브로드캐스트합니다. ### 메시징 및 로그 -- `send`는 채팅 러너 외부에서 채널/계정/스레드 대상 전송을 위한 직접 아웃바운드 전달 RPC입니다. -- `logs.tail`은 커서/제한 및 최대 바이트 제어와 함께 구성된 Gateway 파일 로그 tail을 반환합니다. +- `send`는 chat runner 외부에서 channel/account/thread 대상을 지정해 전송하는 직접 아웃바운드 전달 RPC입니다. +- `logs.tail`은 cursor/limit 및 최대 바이트 제어와 함께 구성된 gateway 파일 로그 tail을 반환합니다. ### Talk 및 TTS -- `talk.config`는 유효한 Talk 설정 페이로드를 반환합니다. `includeSecrets`에는 `operator.talk.secrets`(또는 `operator.admin`)가 필요합니다. -- `talk.mode`는 WebChat/Control UI 클라이언트를 위한 현재 Talk 모드 상태를 설정/브로드캐스트합니다. -- `talk.speak`는 활성 Talk 음성 provider를 통해 음성을 합성합니다. -- `tts.status`는 TTS 활성화 상태, 활성 provider, 폴백 provider, provider 설정 상태를 반환합니다. -- `tts.providers`는 표시 가능한 TTS provider 인벤토리를 반환합니다. -- `tts.enable` 및 `tts.disable`은 TTS 기본 설정 상태를 전환합니다. -- `tts.setProvider`는 선호 TTS provider를 업데이트합니다. -- `tts.convert`는 일회성 텍스트-음성 변환을 실행합니다. +- `talk.config`는 유효한 Talk 구성 페이로드를 반환합니다. `includeSecrets`에는 `operator.talk.secrets`(또는 `operator.admin`)가 필요합니다. +- `talk.mode`는 WebChat/Control UI 클라이언트용 현재 Talk 모드 상태를 설정/브로드캐스트합니다. +- `talk.speak`는 활성 Talk speech 제공자를 통해 음성을 합성합니다. +- `tts.status`는 TTS 활성화 상태, 활성 제공자, 대체 제공자, 제공자 구성 상태를 반환합니다. +- `tts.providers`는 표시 가능한 TTS 제공자 인벤토리를 반환합니다. +- `tts.enable` 및 `tts.disable`은 TTS 기본 설정 상태를 켜거나 끕니다. +- `tts.setProvider`는 선호 TTS 제공자를 업데이트합니다. +- `tts.convert`는 일회성 텍스트 음성 변환을 실행합니다. -### 시크릿, 설정, 업데이트, 그리고 wizard +### 시크릿, 구성, 업데이트, wizard - `secrets.reload`는 활성 SecretRef를 다시 해석하고 전체 성공 시에만 런타임 시크릿 상태를 교체합니다. -- `secrets.resolve`는 특정 명령/대상 집합에 대한 명령 대상 시크릿 할당을 해석합니다. -- `config.get`은 현재 설정 스냅샷과 해시를 반환합니다. -- `config.set`은 검증된 설정 페이로드를 기록합니다. -- `config.patch`는 부분 설정 업데이트를 병합합니다. -- `config.apply`는 전체 설정 페이로드를 검증하고 교체합니다. -- `config.schema`는 Control UI와 CLI 도구에서 사용하는 라이브 설정 스키마 페이로드를 반환합니다: 스키마, `uiHints`, 버전, 생성 메타데이터, 그리고 런타임이 로드할 수 있는 경우 plugin + channel 스키마 메타데이터를 포함합니다. 스키마에는 중첩 객체, 와일드카드, 배열 항목, 그리고 일치하는 필드 문서가 존재하는 경우 `anyOf` / `oneOf` / `allOf` 구성 분기까지 포함해, UI가 사용하는 동일한 레이블 및 도움말 텍스트에서 파생된 필드 `title` / `description` 메타데이터가 포함됩니다. -- `config.schema.lookup`은 하나의 설정 경로에 대한 경로 범위 조회 페이로드를 반환합니다: 정규화된 경로, 얕은 스키마 노드, 일치한 힌트 + `hintPath`, 그리고 UI/CLI 드릴다운을 위한 즉시 하위 항목 요약입니다. - - 조회 스키마 노드는 사용자 대상 문서와 일반적인 검증 필드를 유지합니다: +- `secrets.resolve`는 특정 command/target 집합에 대한 명령 대상 시크릿 할당을 해석합니다. +- `config.get`은 현재 구성 스냅샷과 해시를 반환합니다. +- `config.set`은 검증된 구성 페이로드를 기록합니다. +- `config.patch`는 부분 구성 업데이트를 병합합니다. +- `config.apply`는 전체 구성 페이로드를 검증하고 교체합니다. +- `config.schema`는 Control UI 및 CLI 도구에서 사용하는 라이브 구성 스키마 페이로드를 반환합니다. 스키마, `uiHints`, 버전, 생성 메타데이터를 포함하며, 런타임이 로드할 수 있을 때 plugin + channel 스키마 메타데이터도 포함합니다. 이 스키마에는 UI에서 사용하는 것과 동일한 레이블 및 도움말 텍스트에서 파생된 필드 `title` / `description` 메타데이터가 포함되며, 일치하는 필드 문서가 존재할 경우 중첩 객체, 와일드카드, 배열 항목, `anyOf` / `oneOf` / `allOf` 구성 분기도 포함합니다. +- `config.schema.lookup`은 하나의 구성 경로에 대해 경로 범위 조회 페이로드를 반환합니다. 정규화된 경로, 얕은 스키마 노드, 일치하는 힌트 + `hintPath`, 그리고 UI/CLI 드릴다운을 위한 즉시 하위 요약이 포함됩니다. + - 조회 스키마 노드는 사용자 대상 문서 및 일반적인 검증 필드를 유지합니다: `title`, `description`, `type`, `enum`, `const`, `format`, `pattern`, - 숫자/문자열/배열/객체 경계, 그리고 `additionalProperties`, `deprecated`, `readOnly`, `writeOnly` 같은 불리언 플래그 - - 하위 항목 요약은 `key`, 정규화된 `path`, `type`, `required`, - `hasChildren`, 그리고 일치한 `hint` / `hintPath`를 노출합니다. -- `update.run`은 Gateway 업데이트 흐름을 실행하고 업데이트 자체가 성공한 경우에만 재시작을 예약합니다. + 숫자/문자열/배열/객체 범위, 그리고 `additionalProperties`, + `deprecated`, `readOnly`, `writeOnly` 같은 불리언 플래그. + - 하위 요약은 `key`, 정규화된 `path`, `type`, `required`, + `hasChildren`, 그리고 일치하는 `hint` / `hintPath`를 노출합니다. +- `update.run`은 gateway 업데이트 흐름을 실행하며, 업데이트 자체가 성공한 경우에만 재시작을 예약합니다. - `wizard.start`, `wizard.next`, `wizard.status`, `wizard.cancel`은 WS RPC를 통해 온보딩 wizard를 노출합니다. ### 기존 주요 계열 -#### 에이전트 및 워크스페이스 헬퍼 +#### Agent 및 워크스페이스 헬퍼 -- `agents.list`는 구성된 에이전트 항목을 반환합니다. -- `agents.create`, `agents.update`, `agents.delete`는 에이전트 레코드와 워크스페이스 연결을 관리합니다. -- `agents.files.list`, `agents.files.get`, `agents.files.set`은 에이전트에 대해 노출되는 부트스트랩 워크스페이스 파일을 관리합니다. -- `agent.identity.get`은 에이전트 또는 세션에 대한 유효한 어시스턴트 정체성을 반환합니다. +- `agents.list`는 구성된 agent 항목을 반환합니다. +- `agents.create`, `agents.update`, `agents.delete`는 agent 레코드 및 워크스페이스 연결을 관리합니다. +- `agents.files.list`, `agents.files.get`, `agents.files.set`은 agent에 대해 노출되는 부트스트랩 워크스페이스 파일을 관리합니다. +- `agent.identity.get`은 agent 또는 세션의 유효한 assistant ID를 반환합니다. - `agent.wait`는 실행이 끝날 때까지 기다리고, 가능하면 종료 스냅샷을 반환합니다. #### 세션 제어 - `sessions.list`는 현재 세션 인덱스를 반환합니다. -- `sessions.subscribe`와 `sessions.unsubscribe`는 현재 WS 클라이언트에 대한 세션 변경 이벤트 구독을 전환합니다. -- `sessions.messages.subscribe`와 `sessions.messages.unsubscribe`는 하나의 세션에 대한 대화 내용/메시지 이벤트 구독을 전환합니다. -- `sessions.preview`는 특정 세션 키에 대해 범위가 제한된 대화 내용 미리보기를 반환합니다. +- `sessions.subscribe` 및 `sessions.unsubscribe`는 현재 WS 클라이언트에 대한 세션 변경 이벤트 구독을 켜거나 끕니다. +- `sessions.messages.subscribe` 및 `sessions.messages.unsubscribe`는 하나의 세션에 대한 전사/메시지 이벤트 구독을 켜거나 끕니다. +- `sessions.preview`는 특정 세션 키에 대한 범위 제한 전사 미리보기를 반환합니다. - `sessions.resolve`는 세션 대상을 해석하거나 정규화합니다. - `sessions.create`는 새 세션 항목을 생성합니다. -- `sessions.send`는 기존 세션에 메시지를 보냅니다. -- `sessions.steer`는 활성 세션을 위한 인터럽트 후 조정 변형입니다. +- `sessions.send`는 기존 세션으로 메시지를 보냅니다. +- `sessions.steer`는 활성 세션에 대한 인터럽트 후 조정 변형입니다. - `sessions.abort`는 세션의 활성 작업을 중단합니다. -- `sessions.patch`는 세션 메타데이터/재정의를 업데이트합니다. -- `sessions.reset`, `sessions.delete`, `sessions.compact`는 세션 유지 관리 작업을 수행합니다. +- `sessions.patch`는 세션 메타데이터/오버라이드를 업데이트합니다. +- `sessions.reset`, `sessions.delete`, `sessions.compact`는 세션 유지보수를 수행합니다. - `sessions.get`은 전체 저장된 세션 행을 반환합니다. - 채팅 실행은 여전히 `chat.history`, `chat.send`, `chat.abort`, `chat.inject`를 사용합니다. -- `chat.history`는 UI 클라이언트를 위해 표시 기준으로 정규화됩니다. 인라인 directive 태그는 보이는 텍스트에서 제거되고, 일반 텍스트 tool-call XML 페이로드(`"..."`, `"..."`, `"..."`, `"..."`, 그리고 잘린 tool-call 블록 포함)와 유출된 ASCII/전각 모델 제어 토큰은 제거되며, 정확히 `NO_REPLY` / `no_reply`인 순수 silent-token assistant 행은 생략되고, 과도하게 큰 행은 플레이스홀더로 대체될 수 있습니다. +- `chat.history`는 UI 클라이언트를 위해 표시 정규화됩니다. 인라인 directive 태그는 표시 텍스트에서 제거되고, 일반 텍스트 tool-call XML 페이로드(` ...`, `...`, `...`, `...`, 잘린 tool-call 블록 포함)와 유출된 ASCII/전각 모델 제어 토큰은 제거되며, 정확히 `NO_REPLY` / `no_reply`인 순수 무음 토큰 assistant 행은 생략되고, 지나치게 큰 행은 placeholder로 대체될 수 있습니다. #### 디바이스 페어링 및 디바이스 토큰 - `device.pair.list`는 대기 중 및 승인된 페어링 디바이스를 반환합니다. - `device.pair.approve`, `device.pair.reject`, `device.pair.remove`는 디바이스 페어링 레코드를 관리합니다. -- `device.token.rotate`는 승인된 역할 및 범위 한도 내에서 페어링된 디바이스 토큰을 교체합니다. +- `device.token.rotate`는 승인된 역할 및 범위 제한 내에서 페어링된 디바이스 토큰을 회전합니다. - `device.token.revoke`는 페어링된 디바이스 토큰을 폐기합니다. -#### 노드 페어링, invoke, 대기 중 작업 +#### Node 페어링, invoke, 및 대기 중 작업 -- `node.pair.request`, `node.pair.list`, `node.pair.approve`, `node.pair.reject`, `node.pair.verify`는 노드 페어링과 부트스트랩 검증을 다룹니다. -- `node.list`와 `node.describe`는 알려진/연결된 노드 상태를 반환합니다. -- `node.rename`은 페어링된 노드 레이블을 업데이트합니다. -- `node.invoke`는 연결된 노드로 명령을 전달합니다. +- `node.pair.request`, `node.pair.list`, `node.pair.approve`, `node.pair.reject`, `node.pair.verify`는 node 페어링 및 부트스트랩 검증을 다룹니다. +- `node.list`와 `node.describe`는 알려진/연결된 node 상태를 반환합니다. +- `node.rename`은 페어링된 node 레이블을 업데이트합니다. +- `node.invoke`는 연결된 node에 명령을 전달합니다. - `node.invoke.result`는 invoke 요청의 결과를 반환합니다. -- `node.event`는 노드 발생 이벤트를 다시 게이트웨이로 전달합니다. -- `node.canvas.capability.refresh`는 범위가 지정된 canvas capability 토큰을 새로 고칩니다. -- `node.pending.pull`과 `node.pending.ack`는 연결된 노드 큐 API입니다. -- `node.pending.enqueue`와 `node.pending.drain`은 오프라인/연결 해제된 노드를 위한 지속형 대기 작업을 관리합니다. +- `node.event`는 node에서 시작된 이벤트를 gateway로 다시 전달합니다. +- `node.canvas.capability.refresh`는 범위가 지정된 canvas 기능 토큰을 새로 고칩니다. +- `node.pending.pull` 및 `node.pending.ack`는 연결된 node 큐 API입니다. +- `node.pending.enqueue` 및 `node.pending.drain`은 오프라인/연결 해제된 node를 위한 영속 대기 작업을 관리합니다. #### 승인 계열 -- `exec.approval.request`, `exec.approval.get`, `exec.approval.list`, `exec.approval.resolve`는 일회성 exec 승인 요청과 대기 중 승인 조회/재실행을 다룹니다. +- `exec.approval.request`, `exec.approval.get`, `exec.approval.list`, `exec.approval.resolve`는 일회성 exec 승인 요청과 대기 중 승인 조회/재생을 다룹니다. - `exec.approval.waitDecision`은 하나의 대기 중 exec 승인을 기다리고 최종 결정(또는 타임아웃 시 `null`)을 반환합니다. -- `exec.approvals.get`과 `exec.approvals.set`은 게이트웨이 exec 승인 정책 스냅샷을 관리합니다. -- `exec.approvals.node.get`과 `exec.approvals.node.set`은 노드 릴레이 명령을 통해 노드 로컬 exec 승인 정책을 관리합니다. -- `plugin.approval.request`, `plugin.approval.list`, `plugin.approval.waitDecision`, `plugin.approval.resolve`는 plugin 정의 승인 흐름을 다룹니다. +- `exec.approvals.get` 및 `exec.approvals.set`은 gateway exec 승인 정책 스냅샷을 관리합니다. +- `exec.approvals.node.get` 및 `exec.approvals.node.set`은 node 릴레이 명령을 통해 node 로컬 exec 승인 정책을 관리합니다. +- `plugin.approval.request`, `plugin.approval.list`, `plugin.approval.waitDecision`, `plugin.approval.resolve`는 Plugin 정의 승인 흐름을 다룹니다. #### 기타 주요 계열 -- 자동화: - - `wake`는 즉시 또는 다음 heartbeat에 wake 텍스트 주입을 예약합니다 - - `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove`, `cron.run`, `cron.runs` -- Skills/tools: `commands.list`, `skills.*`, `tools.catalog`, `tools.effective` +- automation: + - `wake`는 즉시 또는 다음 Heartbeat에 웨이크 텍스트 삽입을 예약합니다 + - `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove`, + `cron.run`, `cron.runs` +- skills/tools: `commands.list`, `skills.*`, `tools.catalog`, `tools.effective` ### 일반적인 이벤트 계열 -- `chat`: `chat.inject` 및 기타 대화 내용 전용 채팅 이벤트 같은 UI 채팅 업데이트 -- `session.message`와 `session.tool`: 구독된 세션에 대한 대화 내용/이벤트 스트림 업데이트 -- `sessions.changed`: 세션 인덱스 또는 메타데이터가 변경됨 -- `presence`: 시스템 프레즌스 스냅샷 업데이트 -- `tick`: 주기적인 keepalive / liveness 이벤트 -- `health`: 게이트웨이 상태 스냅샷 업데이트 -- `heartbeat`: heartbeat 이벤트 스트림 업데이트 -- `cron`: cron 실행/작업 변경 이벤트 -- `shutdown`: 게이트웨이 종료 알림 -- `node.pair.requested` / `node.pair.resolved`: 노드 페어링 수명 주기 -- `node.invoke.request`: 노드 invoke 요청 브로드캐스트 -- `device.pair.requested` / `device.pair.resolved`: 페어링 디바이스 수명 주기 -- `voicewake.changed`: 웨이크 워드 트리거 설정이 변경됨 -- `exec.approval.requested` / `exec.approval.resolved`: exec 승인 수명 주기 -- `plugin.approval.requested` / `plugin.approval.resolved`: plugin 승인 수명 주기 +- `chat`: `chat.inject` 및 기타 전사 전용 채팅 이벤트와 같은 UI 채팅 업데이트. +- `session.message` 및 `session.tool`: 구독된 세션에 대한 전사/이벤트 스트림 업데이트. +- `sessions.changed`: 세션 인덱스 또는 메타데이터가 변경됨. +- `presence`: 시스템 프레즌스 스냅샷 업데이트. +- `tick`: 주기적 keepalive / 활성 상태 이벤트. +- `health`: gateway 상태 스냅샷 업데이트. +- `heartbeat`: Heartbeat 이벤트 스트림 업데이트. +- `cron`: Cron 실행/작업 변경 이벤트. +- `shutdown`: gateway 종료 알림. +- `node.pair.requested` / `node.pair.resolved`: node 페어링 수명 주기. +- `node.invoke.request`: node invoke 요청 브로드캐스트. +- `device.pair.requested` / `device.pair.resolved`: 페어링된 디바이스 수명 주기. +- `voicewake.changed`: 웨이크 워드 트리거 구성 변경됨. +- `exec.approval.requested` / `exec.approval.resolved`: exec 승인 수명 주기. +- `plugin.approval.requested` / `plugin.approval.resolved`: Plugin 승인 수명 주기. -### 노드 헬퍼 메서드 +### Node 헬퍼 메서드 -- 노드는 자동 허용 검사에 사용할 현재 skill 실행 파일 목록을 가져오기 위해 `skills.bins`를 호출할 수 있습니다. +- Node는 auto-allow 검사에 사용할 현재 skill 실행 파일 목록을 가져오기 위해 `skills.bins`를 호출할 수 있습니다. -### operator 헬퍼 메서드 +### Operator 헬퍼 메서드 -- operator는 에이전트의 런타임 명령 인벤토리를 가져오기 위해 `commands.list`(`operator.read`)를 호출할 수 있습니다. - - `agentId`는 선택 사항이며, 생략하면 기본 에이전트 워크스페이스를 읽습니다. - - `scope`는 기본 `name`이 어떤 표면을 대상으로 하는지 제어합니다: - - `text`는 앞의 `/`가 없는 기본 텍스트 명령 토큰을 반환합니다 - - `native`와 기본값인 `both` 경로는 가능할 때 provider 인식 native 이름을 반환합니다 - - `textAliases`는 `/model` 및 `/m` 같은 정확한 슬래시 별칭을 담습니다. - - `nativeName`은 존재할 경우 provider 인식 native 명령 이름을 담습니다. - - `provider`는 선택 사항이며 native 이름 지정과 native plugin 명령 가용성에만 영향을 줍니다. - - `includeArgs=false`는 응답에서 직렬화된 인자 메타데이터를 제외합니다. -- operator는 에이전트의 런타임 tool 카탈로그를 가져오기 위해 `tools.catalog`(`operator.read`)를 호출할 수 있습니다. 응답에는 그룹화된 tool과 출처 메타데이터가 포함됩니다: +- Operator는 agent의 런타임 명령 인벤토리를 가져오기 위해 `commands.list`(`operator.read`)를 호출할 수 있습니다. + - `agentId`는 선택 사항입니다. 기본 agent 워크스페이스를 읽으려면 생략하세요. + - `scope`는 기본 `name`이 어떤 표면을 대상으로 하는지 제어합니다. + - `text`는 선행 `/` 없이 기본 텍스트 명령 토큰을 반환합니다 + - `native` 및 기본 `both` 경로는 사용 가능할 때 제공자 인식 native 이름을 반환합니다 + - `textAliases`는 `/model`, `/m` 같은 정확한 슬래시 별칭을 담습니다. + - `nativeName`은 존재할 경우 제공자 인식 native 명령 이름을 담습니다. + - `provider`는 선택 사항이며 native 이름 지정과 native Plugin 명령 가용성에만 영향을 줍니다. + - `includeArgs=false`는 응답에서 직렬화된 인수 메타데이터를 생략합니다. +- Operator는 agent의 런타임 도구 카탈로그를 가져오기 위해 `tools.catalog`(`operator.read`)를 호출할 수 있습니다. 응답에는 그룹화된 도구와 출처 메타데이터가 포함됩니다. - `source`: `core` 또는 `plugin` - - `pluginId`: `source="plugin"`일 때 plugin 소유자 - - `optional`: plugin tool이 선택 사항인지 여부 -- operator는 세션의 런타임 유효 tool 인벤토리를 가져오기 위해 `tools.effective`(`operator.read`)를 호출할 수 있습니다. + - `pluginId`: `source="plugin"`일 때 Plugin 소유자 + - `optional`: Plugin 도구가 선택 사항인지 여부 +- Operator는 세션의 런타임 유효 도구 인벤토리를 가져오기 위해 `tools.effective`(`operator.read`)를 호출할 수 있습니다. - `sessionKey`는 필수입니다. - - 게이트웨이는 호출자가 제공한 인증 또는 전달 컨텍스트를 받지 않고 세션으로부터 신뢰된 런타임 컨텍스트를 서버 측에서 도출합니다. - - 응답은 세션 범위이며, core, plugin, channel tool을 포함해 현재 활성 대화가 지금 사용할 수 있는 내용을 반영합니다. -- operator는 에이전트의 표시 가능한 skill 인벤토리를 가져오기 위해 `skills.status`(`operator.read`)를 호출할 수 있습니다. - - `agentId`는 선택 사항이며, 생략하면 기본 에이전트 워크스페이스를 읽습니다. - - 응답에는 원시 시크릿 값을 노출하지 않고 적격성, 누락된 요구 사항, 설정 검사, 정리된 설치 옵션이 포함됩니다. -- operator는 ClawHub 디스커버리 메타데이터를 위해 `skills.search`와 `skills.detail`(`operator.read`)를 호출할 수 있습니다. -- operator는 두 가지 모드로 `skills.install`(`operator.admin`)을 호출할 수 있습니다: - - ClawHub 모드: `{ source: "clawhub", slug, version?, force? }`는 기본 에이전트 워크스페이스 `skills/` 디렉터리에 skill 폴더를 설치합니다. - - 게이트웨이 설치 프로그램 모드: `{ name, installId, dangerouslyForceUnsafeInstall?, timeoutMs? }`는 게이트웨이 호스트에서 선언된 `metadata.openclaw.install` 작업을 실행합니다. -- operator는 두 가지 모드로 `skills.update`(`operator.admin`)를 호출할 수 있습니다: - - ClawHub 모드는 추적된 slug 하나 또는 기본 에이전트 워크스페이스의 모든 추적된 ClawHub 설치를 업데이트합니다. - - 설정 모드는 `enabled`, `apiKey`, `env` 같은 `skills.entries.` 값을 패치합니다. + - gateway는 호출자가 제공한 인증 또는 전달 컨텍스트를 받지 않고, 세션에서 신뢰된 런타임 컨텍스트를 서버 측에서 파생합니다. + - 응답은 세션 범위이며 현재 활성 대화가 바로 사용할 수 있는 내용을 반영합니다. 여기에는 core, Plugin, channel 도구가 포함됩니다. +- Operator는 agent의 표시 가능한 skill 인벤토리를 가져오기 위해 `skills.status`(`operator.read`)를 호출할 수 있습니다. + - `agentId`는 선택 사항입니다. 기본 agent 워크스페이스를 읽으려면 생략하세요. + - 응답에는 원시 시크릿 값을 노출하지 않으면서 자격, 누락된 요구 사항, 구성 검사, 정제된 설치 옵션이 포함됩니다. +- Operator는 ClawHub 탐색 메타데이터를 위해 `skills.search` 및 `skills.detail`(`operator.read`)를 호출할 수 있습니다. +- Operator는 두 가지 모드로 `skills.install`(`operator.admin`)을 호출할 수 있습니다. + - ClawHub 모드: `{ source: "clawhub", slug, version?, force? }`는 기본 agent 워크스페이스 `skills/` 디렉터리에 skill 폴더를 설치합니다. + - Gateway 설치 프로그램 모드: `{ name, installId, dangerouslyForceUnsafeInstall?, timeoutMs? }`는 gateway 호스트에서 선언된 `metadata.openclaw.install` 작업을 실행합니다. +- Operator는 두 가지 모드로 `skills.update`(`operator.admin`)를 호출할 수 있습니다. + - ClawHub 모드는 기본 agent 워크스페이스에서 하나의 추적된 slug 또는 모든 추적된 ClawHub 설치를 업데이트합니다. + - Config 모드는 `enabled`, `apiKey`, `env` 같은 `skills.entries.` 값을 패치합니다. ## Exec 승인 -- exec 요청에 승인이 필요하면, 게이트웨이는 `exec.approval.requested`를 브로드캐스트합니다. -- operator 클라이언트는 `exec.approval.resolve`를 호출해 이를 처리합니다(`operator.approvals` 범위 필요). -- `host=node`인 경우 `exec.approval.request`에는 `systemRunPlan`(정규화된 `argv`/`cwd`/`rawCommand`/세션 메타데이터)이 포함되어야 합니다. `systemRunPlan`이 없는 요청은 거부됩니다. -- 승인 후 전달되는 `node.invoke system.run` 호출은 해당 정규 `systemRunPlan`을 권위 있는 명령/cwd/세션 컨텍스트로 재사용합니다. -- 호출자가 prepare와 최종 승인된 `system.run` 전달 사이에서 `command`, `rawCommand`, `cwd`, `agentId`, `sessionKey`를 변경하면, 게이트웨이는 변경된 페이로드를 신뢰하는 대신 실행을 거부합니다. +- exec 요청에 승인이 필요하면 gateway는 `exec.approval.requested`를 브로드캐스트합니다. +- Operator 클라이언트는 `exec.approval.resolve`를 호출하여 해결합니다(`operator.approvals` 범위 필요). +- `host=node`의 경우 `exec.approval.request`에는 `systemRunPlan`(정규 `argv`/`cwd`/`rawCommand`/세션 메타데이터)이 포함되어야 합니다. `systemRunPlan`이 없는 요청은 거부됩니다. +- 승인 후 전달된 `node.invoke system.run` 호출은 해당 정규 `systemRunPlan`을 권한 있는 명령/cwd/세션 컨텍스트로 재사용합니다. +- 호출자가 prepare와 최종 승인된 `system.run` 전달 사이에서 `command`, `rawCommand`, `cwd`, `agentId`, `sessionKey`를 변경하면, gateway는 변경된 페이로드를 신뢰하지 않고 실행을 거부합니다. -## 에이전트 전달 폴백 +## Agent 전달 폴백 - `agent` 요청에는 아웃바운드 전달을 요청하기 위해 `deliver=true`를 포함할 수 있습니다. -- `bestEffortDeliver=false`는 엄격한 동작을 유지합니다. 해결되지 않거나 내부 전용 전달 대상은 `INVALID_REQUEST`를 반환합니다. -- `bestEffortDeliver=true`는 외부로 전달 가능한 경로를 해석할 수 없을 때(예: internal/webchat 세션 또는 모호한 다중 채널 설정) 세션 전용 실행으로의 폴백을 허용합니다. +- `bestEffortDeliver=false`는 엄격한 동작을 유지합니다. 해석되지 않거나 내부 전용인 전달 대상은 `INVALID_REQUEST`를 반환합니다. +- `bestEffortDeliver=true`는 외부 전달 가능한 경로를 해석할 수 없을 때(예: 내부/webchat 세션 또는 모호한 다중 채널 구성) 세션 전용 실행으로 폴백을 허용합니다. ## 버전 관리 -- `PROTOCOL_VERSION`은 `src/gateway/protocol/schema.ts`에 있습니다. -- 클라이언트는 `minProtocol` + `maxProtocol`을 전송하며, 서버는 불일치를 거부합니다. -- 스키마 + 모델은 TypeBox 정의에서 생성됩니다: +- `PROTOCOL_VERSION`은 `src/gateway/protocol/schema/protocol-schemas.ts`에 있습니다. +- 클라이언트는 `minProtocol` + `maxProtocol`을 보내며, 서버는 불일치 시 거부합니다. +- 스키마 + 모델은 TypeBox 정의에서 생성됩니다. - `pnpm protocol:gen` - `pnpm protocol:gen:swift` - `pnpm protocol:check` +### 클라이언트 상수 + +`src/gateway/client.ts`의 참조 클라이언트는 다음 기본값을 사용합니다. 이 값들은 프로토콜 v3 전반에서 안정적이며 서드파티 클라이언트에 기대되는 기준선입니다. + +| 상수 | 기본값 | 소스 | +| ----------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------- | +| `PROTOCOL_VERSION` | `3` | `src/gateway/protocol/schema/protocol-schemas.ts` | +| 요청 타임아웃(RPC당) | `30_000` ms | `src/gateway/client.ts` (`requestTimeoutMs`) | +| 사전 인증 / connect-challenge 타임아웃 | `10_000` ms | `src/gateway/handshake-timeouts.ts` (clamp `250`–`10_000`) | +| 초기 재연결 백오프 | `1_000` ms | `src/gateway/client.ts` (`backoffMs`) | +| 최대 재연결 백오프 | `30_000` ms | `src/gateway/client.ts` (`scheduleReconnect`) | +| 디바이스 토큰 close 이후 빠른 재시도 clamp | `250` ms | `src/gateway/client.ts` | +| `terminate()` 전 강제 중지 유예 시간 | `250` ms | `FORCE_STOP_TERMINATE_GRACE_MS` | +| `stopAndWait()` 기본 타임아웃 | `1_000` ms | `STOP_AND_WAIT_TIMEOUT_MS` | +| 기본 tick 간격(`hello-ok` 이전) | `30_000` ms | `src/gateway/client.ts` | +| Tick 타임아웃 close | 무음이 `tickIntervalMs * 2`를 초과하면 코드 `4000` | `src/gateway/client.ts` | +| `MAX_PAYLOAD_BYTES` | `25 * 1024 * 1024` (25 MB) | `src/gateway/server-constants.ts` | + +서버는 `hello-ok`에서 유효한 `policy.tickIntervalMs`, `policy.maxPayload`, `policy.maxBufferedBytes`를 알립니다. 클라이언트는 핸드셰이크 전 기본값보다 이 값을 따라야 합니다. + ## 인증 -- 공유 시크릿 게이트웨이 인증은 구성된 인증 모드에 따라 `connect.params.auth.token` 또는 `connect.params.auth.password`를 사용합니다. -- Tailscale Serve(`gateway.auth.allowTailscale: true`) 또는 루프백이 아닌 `gateway.auth.mode: "trusted-proxy"` 같은 identity 포함 모드는 `connect.params.auth.*` 대신 요청 헤더에서 연결 인증 검사를 충족합니다. -- 비공개 인그레스 `gateway.auth.mode: "none"`은 공유 시크릿 연결 인증을 완전히 건너뜁니다. 해당 모드를 공개/신뢰할 수 없는 인그레스에 노출하지 마세요. -- 페어링 후 Gateway는 연결 역할 + 범위로 범위가 제한된 **디바이스 토큰**을 발급합니다. 이 토큰은 `hello-ok.auth.deviceToken`으로 반환되며, 클라이언트는 이후 연결을 위해 이를 저장해야 합니다. -- 클라이언트는 성공적으로 연결한 후 기본 `hello-ok.auth.deviceToken`을 저장해야 합니다. -- 저장된 디바이스 토큰으로 다시 연결할 때는 그 토큰에 대해 저장된 승인 범위 집합도 함께 재사용해야 합니다. 이렇게 하면 이미 부여된 읽기/프로브/상태 액세스를 유지하고, 재연결이 더 좁은 암묵적 관리자 전용 범위로 조용히 축소되는 일을 방지할 수 있습니다. -- 일반적인 연결 인증 우선순위는 명시적 공유 토큰/비밀번호 우선, 그다음 명시적 `deviceToken`, 그다음 저장된 디바이스별 토큰, 마지막으로 부트스트랩 토큰입니다. -- 추가 `hello-ok.auth.deviceTokens` 항목은 부트스트랩 핸드오프 토큰입니다. `wss://` 또는 루프백/로컬 페어링 같은 신뢰된 전송 계층에서 부트스트랩 인증을 사용해 연결한 경우에만 이를 저장하세요. -- 클라이언트가 명시적 `deviceToken` 또는 명시적 `scopes`를 제공하면, 호출자가 요청한 그 범위 집합이 계속 권위 있는 값으로 유지됩니다. 캐시된 범위는 클라이언트가 저장된 디바이스별 토큰을 재사용하는 경우에만 재사용됩니다. -- 디바이스 토큰은 `device.token.rotate`와 `device.token.revoke`를 통해 교체/폐기할 수 있습니다(`operator.pairing` 범위 필요). -- 토큰 발급/교체는 해당 디바이스의 페어링 항목에 기록된 승인 역할 집합 범위 내로 제한됩니다. 토큰을 교체해도 페어링 승인에서 한 번도 허용하지 않은 역할로 디바이스를 확장할 수는 없습니다. -- 페어링된 디바이스 토큰 세션에서, 호출자에게 `operator.admin`도 있지 않은 한 디바이스 관리는 자체 범위로 제한됩니다. 관리자가 아닌 호출자는 **자신의** 디바이스 항목만 제거/폐기/교체할 수 있습니다. -- `device.token.rotate`는 요청된 operator 범위 집합을 호출자의 현재 세션 범위와도 비교합니다. 관리자가 아닌 호출자는 현재 자신이 가진 것보다 더 넓은 operator 범위 집합으로 토큰을 교체할 수 없습니다. -- 인증 실패에는 `error.details.code`와 함께 복구 힌트가 포함됩니다: - - `error.details.canRetryWithDeviceToken` (불리언) +- 공유 시크릿 gateway 인증은 구성된 인증 모드에 따라 `connect.params.auth.token` 또는 `connect.params.auth.password`를 사용합니다. +- Tailscale Serve(`gateway.auth.allowTailscale: true`) 또는 루프백이 아닌 `gateway.auth.mode: "trusted-proxy"` 같은 ID 기반 모드는 `connect.params.auth.*` 대신 요청 헤더에서 연결 인증 검사를 충족합니다. +- 프라이빗 인그레스의 `gateway.auth.mode: "none"`은 공유 시크릿 연결 인증을 완전히 건너뜁니다. 이 모드를 공개/비신뢰 인그레스에 노출하지 마세요. +- 페어링 후 Gateway는 연결 역할 + 범위에 한정된 **디바이스 토큰**을 발급합니다. 이 토큰은 `hello-ok.auth.deviceToken`에 반환되며, 클라이언트는 이후 연결을 위해 이를 저장해야 합니다. +- 클라이언트는 연결이 성공할 때마다 기본 `hello-ok.auth.deviceToken`을 저장해야 합니다. +- 저장된 해당 **디바이스 토큰**으로 재연결할 때는 그 토큰에 대해 저장된 승인 범위 집합도 재사용해야 합니다. 이렇게 하면 이미 부여된 읽기/프로브/상태 접근이 유지되고, 재연결이 더 좁은 암묵적 admin 전용 범위로 조용히 축소되는 일을 방지할 수 있습니다. +- 클라이언트 측 연결 인증 조립(`src/gateway/client.ts`의 `selectConnectAuth`): + - `auth.password`는 별개이며 설정된 경우 항상 전달됩니다. + - `auth.token`은 우선순위 순서대로 채워집니다. 먼저 명시적 공유 토큰, 그다음 명시적 `deviceToken`, 그다음 저장된 디바이스별 토큰(`deviceId` + `role` 기준)입니다. + - `auth.bootstrapToken`은 위 항목들 중 어느 것도 `auth.token`으로 해결되지 않았을 때만 전송됩니다. 공유 토큰이나 해결된 디바이스 토큰이 있으면 전송되지 않습니다. + - 일회성 `AUTH_TOKEN_MISMATCH` 재시도에서 저장된 디바이스 토큰을 자동 승격하는 동작은 **신뢰된 엔드포인트만** 허용됩니다. 즉 루프백이거나, 고정된 `tlsFingerprint`가 있는 `wss://`여야 합니다. 핀 고정이 없는 공개 `wss://`는 해당되지 않습니다. +- 추가 `hello-ok.auth.deviceTokens` 항목은 부트스트랩 핸드오프 토큰입니다. `wss://` 또는 루프백/로컬 페어링 같은 신뢰된 전송에서 부트스트랩 인증을 사용한 경우에만 이를 저장하세요. +- 클라이언트가 **명시적** `deviceToken` 또는 명시적 `scopes`를 제공하면, 호출자가 요청한 그 범위 집합이 계속 권한 기준이 됩니다. 캐시된 범위는 클라이언트가 저장된 디바이스별 토큰을 재사용할 때만 재사용됩니다. +- 디바이스 토큰은 `device.token.rotate` 및 `device.token.revoke`로 회전/폐기할 수 있습니다(`operator.pairing` 범위 필요). +- 토큰 발급/회전은 해당 디바이스의 페어링 항목에 기록된 승인 역할 집합 범위 내로 제한됩니다. 토큰을 회전한다고 해서 페어링 승인이 한 번도 부여하지 않은 역할로 디바이스를 확장할 수는 없습니다. +- 페어링된 디바이스 토큰 세션의 경우, 호출자에게 `operator.admin`도 있는 것이 아니라면 디바이스 관리는 자기 범위로 제한됩니다. admin이 아닌 호출자는 자신의 **자기 디바이스 항목**만 제거/폐기/회전할 수 있습니다. +- `device.token.rotate`는 요청된 operator 범위 집합을 호출자의 현재 세션 범위와도 비교합니다. admin이 아닌 호출자는 자신이 이미 보유한 것보다 더 넓은 operator 범위 집합으로 토큰을 회전할 수 없습니다. +- 인증 실패에는 `error.details.code`와 복구 힌트가 포함됩니다. + - `error.details.canRetryWithDeviceToken` (boolean) - `error.details.recommendedNextStep` (`retry_with_device_token`, `update_auth_configuration`, `update_auth_credentials`, `wait_then_retry`, `review_auth_configuration`) - `AUTH_TOKEN_MISMATCH`에 대한 클라이언트 동작: - 신뢰된 클라이언트는 캐시된 디바이스별 토큰으로 한 번의 제한된 재시도를 시도할 수 있습니다. - - 그 재시도가 실패하면, 클라이언트는 자동 재연결 루프를 중단하고 운영자 조치 안내를 표시해야 합니다. + - 그 재시도가 실패하면, 클라이언트는 자동 재연결 루프를 중단하고 operator 조치 안내를 표시해야 합니다. -## 디바이스 정체성 + 페어링 +## 디바이스 ID + 페어링 -- 노드는 키 쌍 지문에서 파생된 안정적인 디바이스 정체성(`device.id`)을 포함해야 합니다. -- 게이트웨이는 디바이스 + 역할별로 토큰을 발급합니다. -- 로컬 자동 승인이 활성화되어 있지 않다면, 새 디바이스 ID에는 페어링 승인이 필요합니다. -- 페어링 자동 승인은 직접적인 local loopback 연결을 중심으로 동작합니다. -- OpenClaw에는 신뢰된 공유 시크릿 헬퍼 흐름을 위한, 범위가 좁은 백엔드/컨테이너 로컬 self-connect 경로도 있습니다. -- 동일 호스트의 tailnet 또는 LAN 연결은 여전히 페어링 관점에서 원격으로 취급되며 승인이 필요합니다. -- 모든 WS 클라이언트는 `connect` 중에 `device` 정체성을 포함해야 합니다(operator + node). +- Node는 키 쌍 지문에서 파생된 안정적인 디바이스 ID(`device.id`)를 포함해야 합니다. +- Gateway는 디바이스 + 역할별로 토큰을 발급합니다. +- 로컬 자동 승인이 활성화되지 않은 경우, 새 디바이스 ID에는 페어링 승인이 필요합니다. +- 페어링 자동 승인은 직접적인 로컬 local loopback 연결을 중심으로 합니다. +- OpenClaw는 신뢰된 공유 시크릿 헬퍼 흐름을 위한 좁은 백엔드/컨테이너 로컬 self-connect 경로도 제공합니다. +- 동일 호스트의 tailnet 또는 LAN 연결은 여전히 원격으로 취급되며 페어링 승인이 필요합니다. +- 모든 WS 클라이언트는 `connect` 중에 `device` ID를 포함해야 합니다(operator + node). Control UI는 다음 모드에서만 이를 생략할 수 있습니다: - - localhost 전용 비보안 HTTP 호환성을 위한 `gateway.controlUi.allowInsecureAuth=true` - - 성공적인 `gateway.auth.mode: "trusted-proxy"` operator Control UI 인증 - - `gateway.controlUi.dangerouslyDisableDeviceAuth=true` (비상용, 심각한 보안 저하) + - localhost 전용 비보안 HTTP 호환성을 위한 `gateway.controlUi.allowInsecureAuth=true`. + - 성공한 `gateway.auth.mode: "trusted-proxy"` operator Control UI 인증. + - `gateway.controlUi.dangerouslyDisableDeviceAuth=true` (비상용, 심각한 보안 저하). - 모든 연결은 서버가 제공한 `connect.challenge` nonce에 서명해야 합니다. ### 디바이스 인증 마이그레이션 진단 -여전히 사전 챌린지 서명 동작을 사용하는 레거시 클라이언트를 위해, 이제 `connect`는 `error.details.reason`과 함께 안정적인 `error.details.code` 아래 `DEVICE_AUTH_*` 상세 코드를 반환합니다. +이전 챌린지 이전 서명 동작을 여전히 사용하는 레거시 클라이언트를 위해, 이제 `connect`는 `error.details.reason`이 안정적으로 유지되는 `error.details.code` 아래 `DEVICE_AUTH_*` 세부 코드를 반환합니다. 일반적인 마이그레이션 실패: -| 메시지 | details.code | details.reason | 의미 | -| ------ | ------------ | -------------- | ---- | -| `device nonce required` | `DEVICE_AUTH_NONCE_REQUIRED` | `device-nonce-missing` | 클라이언트가 `device.nonce`를 생략했거나 빈 값을 보냈습니다. | -| `device nonce mismatch` | `DEVICE_AUTH_NONCE_MISMATCH` | `device-nonce-mismatch` | 클라이언트가 오래되었거나 잘못된 nonce로 서명했습니다. | -| `device signature invalid` | `DEVICE_AUTH_SIGNATURE_INVALID` | `device-signature` | 서명 페이로드가 v2 페이로드와 일치하지 않습니다. | -| `device signature expired` | `DEVICE_AUTH_SIGNATURE_EXPIRED` | `device-signature-stale` | 서명된 타임스탬프가 허용된 시간 오차 범위를 벗어났습니다. | -| `device identity mismatch` | `DEVICE_AUTH_DEVICE_ID_MISMATCH` | `device-id-mismatch` | `device.id`가 공개 키 지문과 일치하지 않습니다. | -| `device public key invalid` | `DEVICE_AUTH_PUBLIC_KEY_INVALID` | `device-public-key` | 공개 키 형식 또는 정규화에 실패했습니다. | +| 메시지 | details.code | details.reason | 의미 | +| --------------------------- | -------------------------------- | ------------------------ | -------------------------------------------------- | +| `device nonce required` | `DEVICE_AUTH_NONCE_REQUIRED` | `device-nonce-missing` | 클라이언트가 `device.nonce`를 생략했거나 빈 값을 보냈습니다. | +| `device nonce mismatch` | `DEVICE_AUTH_NONCE_MISMATCH` | `device-nonce-mismatch` | 클라이언트가 오래되었거나 잘못된 nonce로 서명했습니다. | +| `device signature invalid` | `DEVICE_AUTH_SIGNATURE_INVALID` | `device-signature` | 서명 페이로드가 v2 페이로드와 일치하지 않습니다. | +| `device signature expired` | `DEVICE_AUTH_SIGNATURE_EXPIRED` | `device-signature-stale` | 서명된 타임스탬프가 허용된 오차 범위를 벗어났습니다. | +| `device identity mismatch` | `DEVICE_AUTH_DEVICE_ID_MISMATCH` | `device-id-mismatch` | `device.id`가 공개 키 지문과 일치하지 않습니다. | +| `device public key invalid` | `DEVICE_AUTH_PUBLIC_KEY_INVALID` | `device-public-key` | 공개 키 형식/정규화에 실패했습니다. | 마이그레이션 목표: - 항상 `connect.challenge`를 기다리세요. -- 서버 nonce가 포함된 v2 페이로드에 서명하세요. +- 서버 nonce를 포함하는 v2 페이로드에 서명하세요. - 동일한 nonce를 `connect.params.device.nonce`에 보내세요. - 권장 서명 페이로드는 `v3`이며, 이는 device/client/role/scopes/token/nonce 필드에 더해 `platform`과 `deviceFamily`를 바인딩합니다. -- 레거시 `v2` 서명은 호환성을 위해 계속 허용되지만, 페어링된 디바이스 메타데이터 고정은 재연결 시에도 계속 명령 정책을 제어합니다. +- 레거시 `v2` 서명은 호환성을 위해 계속 허용되지만, 페어링된 디바이스 메타데이터 고정은 재연결 시 명령 정책을 계속 제어합니다. ## TLS + 핀 고정 -- WS 연결에 TLS를 지원합니다. -- 클라이언트는 선택적으로 게이트웨이 인증서 지문을 핀 고정할 수 있습니다(`gateway.tls` 설정과 `gateway.remote.tlsFingerprint` 또는 CLI `--tls-fingerprint` 참조). +- WS 연결에 TLS가 지원됩니다. +- 클라이언트는 필요에 따라 gateway 인증서 지문을 핀 고정할 수 있습니다(`gateway.tls` 구성과 `gateway.remote.tlsFingerprint` 또는 CLI `--tls-fingerprint` 참조). ## 범위 -이 프로토콜은 **전체 게이트웨이 API**(status, channels, models, chat, agent, sessions, nodes, approvals 등)를 노출합니다. 정확한 표면은 `src/gateway/protocol/schema.ts`의 TypeBox 스키마로 정의됩니다. +이 프로토콜은 **전체 gateway API**(상태, 채널, 모델, 채팅, agent, 세션, node, 승인 등)를 노출합니다. 정확한 표면은 `src/gateway/protocol/schema.ts`의 TypeBox 스키마에서 정의됩니다. diff --git a/docs/ko/refactor/async-exec-duplicate-completion-investigation.md b/docs/ko/refactor/async-exec-duplicate-completion-investigation.md new file mode 100644 index 000000000..14b39fca1 --- /dev/null +++ b/docs/ko/refactor/async-exec-duplicate-completion-investigation.md @@ -0,0 +1,132 @@ +--- +x-i18n: + generated_at: "2026-04-16T06:00:21Z" + model: gpt-5.4 + provider: openai + source_hash: 95e56c5411204363676f002059c942201503e2359515d1a4b409882cc2e04920 + source_path: refactor/async-exec-duplicate-completion-investigation.md + workflow: 15 +--- + +# 비동기 Exec 중복 완료 조사 + +## 범위 + +- 세션: `agent:main:telegram:group:-1003774691294:topic:1` +- 증상: 동일한 비동기 exec 완료가 세션/실행 `keen-nexus`에 대해 LCM에 사용자 턴으로 두 번 기록되었습니다. +- 목표: 이것이 중복 세션 주입인지, 아니면 단순한 아웃바운드 전달 재시도인지 식별합니다. + +## 결론 + +가장 가능성이 높은 것은 순수한 아웃바운드 전달 재시도가 아니라 **중복 세션 주입**입니다. + +게이트웨이 측에서 가장 강한 결함 지점은 **node exec 완료 경로**입니다: + +1. node 측 exec 종료가 전체 `runId`와 함께 `exec.finished`를 내보냅니다. +2. Gateway의 `server-node-events`가 이를 시스템 이벤트로 변환하고 Heartbeat를 요청합니다. +3. Heartbeat 실행이 비워진 시스템 이벤트 블록을 에이전트 프롬프트에 주입합니다. +4. 임베디드 러너가 그 프롬프트를 세션 transcript의 새 사용자 턴으로 영속화합니다. + +어떤 이유로든 동일한 `runId`에 대해 같은 `exec.finished`가 Gateway에 두 번 도달하면(재생, 재연결 중복, 업스트림 재전송, 중복된 producer), OpenClaw는 현재 이 경로에서 `runId`/`contextKey`를 키로 하는 **멱등성 검사**가 없습니다. 두 번째 복사본은 같은 내용의 두 번째 사용자 메시지가 됩니다. + +## 정확한 코드 경로 + +### 1. Producer: node exec 완료 이벤트 + +- `src/node-host/invoke.ts:340-360` + - `sendExecFinishedEvent(...)`가 이벤트 `exec.finished`와 함께 `node.event`를 내보냅니다. + - payload에는 `sessionKey`와 전체 `runId`가 포함됩니다. + +### 2. Gateway 이벤트 수집 + +- `src/gateway/server-node-events.ts:574-640` + - `exec.finished`를 처리합니다. + - 다음 텍스트를 구성합니다: + - `Exec finished (node=..., id=, code ...)` + - 이를 다음으로 큐에 넣습니다: + - `enqueueSystemEvent(text, { sessionKey, contextKey: runId ? \`exec:${runId}\` : "exec", trusted: false })` + - 그리고 즉시 wake를 요청합니다: + - `requestHeartbeatNow(scopedHeartbeatWakeOptions(sessionKey, { reason: "exec-event" }))` + +### 3. 시스템 이벤트 중복 제거 취약점 + +- `src/infra/system-events.ts:90-115` + - `enqueueSystemEvent(...)`는 **연속된 동일 텍스트**만 억제합니다: + - `if (entry.lastText === cleaned) return false` + - `contextKey`를 저장하지만, 멱등성을 위해 `contextKey`를 사용하지는 않습니다. + - drain 이후에는 중복 억제가 초기화됩니다. + +즉, 동일한 `runId`를 가진 재생된 `exec.finished`는 나중에 다시 허용될 수 있으며, 코드에는 이미 안정적인 멱등성 후보(`exec:`)가 있었음에도 그렇습니다. + +### 4. Wake 처리가 주된 중복 원인은 아님 + +- `src/infra/heartbeat-wake.ts:79-117` + - wake는 `(agentId, sessionKey)` 기준으로 병합됩니다. + - 동일한 대상에 대한 중복 wake 요청은 하나의 대기 중 wake 항목으로 합쳐집니다. + +따라서 **중복 wake 처리만으로는** 중복 이벤트 수집보다 설명력이 약합니다. + +### 5. Heartbeat가 이벤트를 소비해 프롬프트 입력으로 변환 + +- `src/infra/heartbeat-runner.ts:535-574` + - preflight가 대기 중인 시스템 이벤트를 미리 보고 exec-event 실행을 분류합니다. +- `src/auto-reply/reply/session-system-events.ts:86-90` + - `drainFormattedSystemEvents(...)`가 세션의 큐를 비웁니다. +- `src/auto-reply/reply/get-reply-run.ts:400-427` + - 비워진 시스템 이벤트 블록이 에이전트 프롬프트 본문 앞에 prepend됩니다. + +### 6. Transcript 주입 지점 + +- `src/agents/pi-embedded-runner/run/attempt.ts:2000-2017` + - `activeSession.prompt(effectivePrompt)`가 전체 프롬프트를 임베디드 PI 세션에 제출합니다. + - 완료 기반 프롬프트가 영속화된 사용자 턴이 되는 지점이 바로 여기입니다. + +따라서 동일한 시스템 이벤트가 두 번 다시 프롬프트로 구성되면, LCM에서 중복 사용자 메시지가 생기는 것은 예상된 결과입니다. + +## 단순 아웃바운드 전달 재시도 가능성이 더 낮은 이유 + +Heartbeat runner에는 실제 아웃바운드 실패 경로가 있습니다: + +- `src/infra/heartbeat-runner.ts:1194-1242` + - 먼저 응답이 생성됩니다. + - 그 뒤에 `deliverOutboundPayloads(...)`를 통해 아웃바운드 전달이 수행됩니다. + - 여기서 실패하면 `{ status: "failed" }`를 반환합니다. + +하지만 동일한 시스템 이벤트 큐 항목에 대해 이것만으로는 **중복 사용자 턴**을 설명하기에 충분하지 않습니다: + +- `src/auto-reply/reply/session-system-events.ts:86-90` + - 시스템 이벤트 큐는 아웃바운드 전달 전에 이미 비워집니다. + +따라서 채널 전송 재시도만으로는 동일한 큐 이벤트가 다시 생성되지 않습니다. 외부 전달 누락/실패는 설명할 수 있지만, 동일한 세션 사용자 메시지가 두 번째로 생기는 현상 자체를 단독으로 설명하지는 못합니다. + +## 2차적이며 신뢰도는 더 낮은 가능성 + +에이전트 runner에는 전체 실행 재시도 루프가 있습니다: + +- `src/auto-reply/reply/agent-runner-execution.ts:741-1473` + - 특정 일시적 실패는 전체 실행을 재시도하고 같은 `commandBody`를 다시 제출할 수 있습니다. + +이 경우 재시도 조건이 트리거되기 전에 프롬프트가 이미 append되었다면, **동일한 응답 실행 내에서** 영속화된 사용자 프롬프트가 중복될 수 있습니다. + +제가 이것을 중복 `exec.finished` 수집보다 낮게 평가하는 이유는 다음과 같습니다: + +- 관측된 간격이 약 51초였는데, 이는 프로세스 내부 재시도보다는 두 번째 wake/turn에 더 가까워 보입니다. +- 보고서에는 반복된 메시지 전송 실패도 언급되어 있는데, 이는 즉각적인 모델/런타임 재시도보다는 분리된 나중 턴을 더 시사합니다. + +## 근본 원인 가설 + +가장 신뢰도가 높은 가설: + +- `keen-nexus` 완료는 **node exec 이벤트 경로**를 통해 들어왔습니다. +- 동일한 `exec.finished`가 `server-node-events`에 두 번 전달되었습니다. +- Gateway는 `enqueueSystemEvent(...)`가 `contextKey` / `runId` 기준으로 중복 제거를 하지 않기 때문에 둘 다 수용했습니다. +- 수용된 각 이벤트가 Heartbeat를 트리거했고, PI transcript에 사용자 턴으로 주입되었습니다. + +## 제안하는 작고 정밀한 수정 + +수정이 필요하다면, 가장 작으면서 효과가 큰 변경은 다음과 같습니다: + +- 적어도 정확히 동일한 `(sessionKey, contextKey, text)` 반복에 대해서는 짧은 기간 동안 exec/시스템 이벤트 멱등성이 `contextKey`를 존중하도록 합니다. +- 또는 `(sessionKey, runId, event kind)`를 키로 하는 `exec.finished` 전용 중복 제거를 `server-node-events`에 추가합니다. + +이렇게 하면 재생된 `exec.finished` 중복이 세션 턴이 되기 전에 직접 차단됩니다.