arkclaw-webchat-cli 0.5.1__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/PKG-INFO +1 -1
  2. {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/pyproject.toml +1 -1
  3. {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/config.py +18 -0
  4. {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/errors.py +7 -0
  5. {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/flows.py +135 -49
  6. {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/identity.py +14 -0
  7. {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/sts.py +2 -2
  8. {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/transport/openclaw.py +43 -4
  9. {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/.gitignore +0 -0
  10. {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/README.md +0 -0
  11. {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/__init__.py +0 -0
  12. {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/attachments.py +0 -0
  13. {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/cli.py +0 -0
  14. {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/control.py +0 -0
  15. {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/core.py +0 -0
  16. {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/doctor.py +0 -0
  17. {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/oauth.py +0 -0
  18. {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/output.py +0 -0
  19. {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/policy.py +0 -0
  20. {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/providers.py +0 -0
  21. {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/secrets_store.py +0 -0
  22. {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/transport/__init__.py +0 -0
  23. {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/transport/a2a.py +0 -0
  24. {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/transport/base.py +0 -0
  25. {arkclaw_webchat_cli-0.5.1 → arkclaw_webchat_cli-0.6.0}/src/ee_claw/update.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arkclaw-webchat-cli
3
- Version: 0.5.1
3
+ Version: 0.6.0
4
4
  Summary: CLI to chat with an ArkClaw EE space's Claw over enterprise SSO — zero permanent AK/SK.
5
5
  Author: ArkClaw Team
6
6
  Keywords: arkclaw,cli,ee,openclaw,sso,sts
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "arkclaw-webchat-cli"
7
- version = "0.5.1"
7
+ version = "0.6.0"
8
8
  description = "CLI to chat with an ArkClaw EE space's Claw over enterprise SSO — zero permanent AK/SK."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -126,6 +126,24 @@ def get_session(space: str, claw: str, session: str) -> dict[str, object] | None
126
126
  return entry if isinstance(entry, dict) else None
127
127
 
128
128
 
129
+ def forget_claw(space: str, claw: str) -> bool:
130
+ """Drop ALL recorded sessions for a (space, claw) — used to self-heal the
131
+ `agents` list when a claw turns out to be inaccessible. Returns True if any
132
+ entry was removed."""
133
+ data = _load_sessions()
134
+ prefix = f"{space}|{claw}|"
135
+ doomed = [k for k in data if k.startswith(prefix)]
136
+ if not doomed:
137
+ return False
138
+ for k in doomed:
139
+ del data[k]
140
+ _dir()
141
+ fd = os.open(_sessions_path(), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
142
+ with os.fdopen(fd, "w") as f:
143
+ f.write(json.dumps(data, ensure_ascii=False))
144
+ return True
145
+
146
+
129
147
  # --- Named-agent registry ------------------------------------------------------
130
148
  # Maps a human-friendly agent name (the a2a agent-card name, an openclaw claw
131
149
  # Name) → a non-secret connection snapshot, so `arkclaw chat <name>` works.
@@ -83,6 +83,13 @@ class StsError(AuthError):
83
83
  code = "ARKCLAW_E_STS"
84
84
 
85
85
 
86
+ class ClawAccessDeniedError(StsError):
87
+ """The user is not authorized for this specific claw (server per-user gate).
88
+ Distinct so callers can prune it from local history (self-healing)."""
89
+
90
+ code = "ARKCLAW_E_FORBIDDEN"
91
+
92
+
86
93
  # --- Secret redaction -------------------------------------------------------
87
94
  # Never let a full token reach the terminal, a log, or an error message.
88
95
  #
@@ -11,6 +11,7 @@ and every error is a typed :class:`~ee_claw.errors.ArkclawError`.
11
11
  from __future__ import annotations
12
12
 
13
13
  import asyncio
14
+ import dataclasses
14
15
  import json
15
16
  import os
16
17
  import pathlib
@@ -29,6 +30,7 @@ from ee_claw.attachments import collect_files
29
30
  from ee_claw.config import SessionConfig
30
31
  from ee_claw.errors import (
31
32
  ArkclawError,
33
+ ClawAccessDeniedError,
32
34
  ExpiredError,
33
35
  NetworkError,
34
36
  NoClawError,
@@ -51,7 +53,7 @@ from ee_claw.identity import (
51
53
  from ee_claw.oauth import OAuthClient
52
54
  from ee_claw.output import Emitter
53
55
  from ee_claw.providers import ProviderContext, resolve_token_source
54
- from ee_claw.sts import assume_role_with_oidc
56
+ from ee_claw.sts import assume_role_with_oidc, get_chat_token
55
57
  from ee_claw.transport.a2a import A2ATransport, agent_card
56
58
  from ee_claw.transport.base import Approver, Attachment, Transport, TurnEvent, TurnResult
57
59
  from ee_claw.transport.openclaw import OpenClawTransport, session_key_for
@@ -114,6 +116,37 @@ def _user_identity(id_token: str | None) -> dict[str, str]:
114
116
  return ident
115
117
 
116
118
 
119
+ def _probe_claw_access(
120
+ cfg: SessionConfig, id_token: str, claw_ids: list[str]
121
+ ) -> tuple[set[str], set[str]]:
122
+ """Which of ``claw_ids`` the current user can ACTUALLY chat — checked live by
123
+ minting a (throwaway) ChatToken for each, the only per-user check the CLI's
124
+ role can do. Returns (accessible, denied). A transient/non-permission error
125
+ keeps the claw (don't hide it on a network blip). Raises ArkclawError if
126
+ creds can't be obtained at all (caller falls back to the unchecked list)."""
127
+ from concurrent.futures import ThreadPoolExecutor
128
+
129
+ creds = _openclaw_creds(cfg, id_token) # may raise (offline / expired / no creds)
130
+ identity = _user_identity(id_token)
131
+ region = str(cfg.region)
132
+
133
+ def check(cid: str) -> tuple[str, bool]:
134
+ try:
135
+ get_chat_token(cid, region, creds, identity=identity)
136
+ return cid, True
137
+ except ClawAccessDeniedError:
138
+ return cid, False
139
+ except ArkclawError:
140
+ return cid, True # transient/other — keep, don't silently drop
141
+
142
+ accessible: set[str] = set()
143
+ denied: set[str] = set()
144
+ with ThreadPoolExecutor(max_workers=min(8, max(1, len(claw_ids)))) as ex:
145
+ for cid, ok in ex.map(check, claw_ids):
146
+ (accessible if ok else denied).add(cid)
147
+ return accessible, denied
148
+
149
+
117
150
  def _normalize(url: str) -> tuple[str, str]:
118
151
  base = (url if "//" in url else "https://" + url).rstrip("/")
119
152
  host = urllib.parse.urlparse(base).hostname or ""
@@ -129,55 +162,72 @@ def _is_userpool_address(host: str) -> bool:
129
162
 
130
163
 
131
164
  def do_init(emitter: Emitter, address: str | None = None) -> dict[str, Any]:
132
- """``arkclaw init``: one-time interactive setup. Captures the login address
133
- plus the bits not yet auto-discoverable (the CLI's OAuth client, the STS
134
- role) so that afterwards ``arkclaw login`` needs no env vars or flags.
135
- Anything the space already self-describes (issuer, region, and once the
136
- platform publishes it client_id/role) is auto-filled and not asked."""
137
- if emitter.json or not sys.stdin.isatty():
138
- raise ValidationError(
139
- "arkclaw init 是交互式的,需要终端。",
140
- hint="在终端直接运行 `arkclaw init`;脚本里改用 env(ARKCLAW_CLIENT_ID/ROLE_TRN)+ flags。",
141
- )
165
+ """``arkclaw init``: one-time setup. The GOAL is **address-only** — when the
166
+ space publishes its CLI config (issuer/region/client_id/role…) via
167
+ ``/.well-known/arkclaw-cli`` or GetAuthConfig, the user gives ONLY the
168
+ address and nothing is asked (works non-interactively too). Until then, it
169
+ prompts for the few bits discovery can't provide (the CLI's OAuth client,
170
+ the STS role)."""
171
+ interactive = not emitter.json and sys.stdin.isatty()
172
+
173
+ def need_tty() -> None:
174
+ if not interactive:
175
+ raise ValidationError(
176
+ "该空间尚未发布 CLI 配置,需要补充 client_id/role,而当前非交互终端。",
177
+ hint="在终端运行 `arkclaw init <地址>`;或脚本里用 env(ARKCLAW_CLIENT_ID/ROLE_TRN)+ flags。",
178
+ )
142
179
 
143
- def ask(label: str, default: str | None = None, *, required: bool = False) -> str | None:
180
+ def ask(label: str, default: str | None = None, *, required: bool = False) -> str:
144
181
  suffix = f" [{default}]" if default else ""
145
182
  while True:
146
183
  ans = input(f" {label}{suffix}: ").strip() or (default or "")
147
184
  if ans or not required:
148
- return ans or None
185
+ return ans
149
186
  emitter.line(" (必填)")
150
187
 
151
- emitter.line("arkclaw init —— 一次性配置(能自动发现的不会问你)\n")
152
- address = address or ask("空间登录地址 (https://…)", required=True)
188
+ if not address:
189
+ need_tty()
190
+ emitter.line("arkclaw init —— 配置一次(能自动发现的不问你)\n")
191
+ address = ask("空间登录地址 (https://…)", required=True)
153
192
  base, host = _normalize(str(address))
154
193
  disc = discover_cli_config(base) or {}
155
194
  issuer = disc.get("issuer") or (f"https://{host}" if _is_userpool_address(host) else None)
156
195
  region = disc.get("region") or derive_region(base)
157
- if issuer:
158
- emitter.line(" ✓ 自动发现身份池 issuer")
159
- if region:
160
- emitter.line(f" ✓ 区域: {region}")
161
-
162
- # User-facing name is "webchat" (the ChatToken/wss path); internally it's
163
- # the "openclaw" transport. "a2a" is unchanged.
164
- disc_t = str(disc.get("transport") or "")
165
- transport_in = ask("传输方式 (webchat/a2a)", default=("a2a" if disc_t == "a2a" else "webchat")) or "webchat"
166
- transport = "a2a" if transport_in.lower() == "a2a" else "openclaw"
167
- client_id = disc.get("client_id") or ask(
168
- "CLI client_id(public OAuth 客户端,问管理员;平台发布发现后免此项)", required=True
169
- )
170
- role_trn: object = None
171
- endpoint: object = None
172
- if transport == "openclaw":
173
- role_trn = disc.get("role_trn") or ask("STS role TRN (trn:iam::<账号>:role/<名字>)", required=True)
196
+ transport = "a2a" if str(disc.get("transport") or "").lower() == "a2a" else "openclaw"
197
+ client_id = disc.get("client_id")
198
+ role_trn = disc.get("role_trn")
199
+ provider_trn = disc.get("provider_trn")
200
+ endpoint = disc.get("endpoint")
201
+ clawid = disc.get("clawid")
202
+
203
+ # Fully discovered = the space published this CLI's client + target plane →
204
+ # nothing to ask (address-only). Otherwise prompt ONLY for what's missing.
205
+ complete = bool(client_id) and (bool(role_trn) if transport == "openclaw" else bool(endpoint))
206
+ if complete:
207
+ emitter.line("✓ 该空间已发布 CLI 配置 —— 全部自动解析,无需手动输入。")
174
208
  else:
175
- endpoint = disc.get("endpoint") or ask("A2A endpoint (https://…)", required=True)
176
- clawid = disc.get("clawid") or ask("默认 claw id (ci-…,可留空,登录后用 arkclaw agents 选)")
209
+ need_tty()
210
+ if issuer:
211
+ emitter.line(" ✓ 自动发现身份池 issuer")
212
+ if region:
213
+ emitter.line(f" ✓ 区域: {region}")
214
+ # "webchat" = the ChatToken/wss path (internal name "openclaw"); "a2a" unchanged.
215
+ t_in = ask("传输方式 (webchat/a2a)", default=("a2a" if transport == "a2a" else "webchat"))
216
+ transport = "a2a" if t_in.lower() == "a2a" else "openclaw"
217
+ if not client_id:
218
+ client_id = ask(
219
+ "CLI client_id(public OAuth 客户端,问管理员;平台发布发现后免此项)", required=True
220
+ )
221
+ if transport == "openclaw" and not role_trn:
222
+ role_trn = ask("STS role TRN (trn:iam::<账号>:role/<名字>)", required=True)
223
+ elif transport == "a2a" and not endpoint:
224
+ endpoint = ask("A2A endpoint (https://…)", required=True)
225
+ if not clawid:
226
+ clawid = ask("默认 claw id (ci-…,可留空,登录后用 arkclaw agents 选)") or None
177
227
 
178
228
  saved = {
179
229
  "address": base, "issuer": issuer, "client_id": client_id, "transport": transport,
180
- "region": region, "role_trn": role_trn, "provider_trn": disc.get("provider_trn"),
230
+ "region": region, "role_trn": role_trn, "provider_trn": provider_trn,
181
231
  "endpoint": endpoint, "space_id": disc.get("space_id"), "clawid": clawid,
182
232
  }
183
233
  config_mod.save_defaults(saved)
@@ -706,6 +756,15 @@ def _save_session_state(cfg: SessionConfig, target: str, session: str | None, tr
706
756
  config_mod.record_session(str(cfg.space), target, session or "main", extra)
707
757
 
708
758
 
759
+ def _forget_inaccessible(cfg: SessionConfig, claw: str) -> None:
760
+ """A chat to ``claw`` was denied (not the user's, not shared). Drop it from
761
+ local history so `arkclaw agents` stops listing it, and clear it as the
762
+ default if it was the default (so a bare `arkclaw chat` won't keep failing)."""
763
+ config_mod.forget_claw(str(cfg.space), claw)
764
+ if cfg.claw == claw:
765
+ config_mod.save_config(dataclasses.replace(cfg, claw=None))
766
+
767
+
709
768
  def _resolve_target(name: str) -> SessionConfig | None:
710
769
  """A chat target by name: the agent registry (card/claw names recorded by
711
770
  ``login``/``agents``) first, then profile names."""
@@ -782,13 +841,18 @@ def do_chat(
782
841
  target, transport = _target_transport(
783
842
  cfg, clawid, id_token, session=session, approver=approver
784
843
  )
785
- config_mod.record_session(str(cfg.space), target, session or "main")
786
-
844
+ # NOTE: do NOT record the session here (before the turn) a denied claw
845
+ # would pollute `arkclaw agents`. Recording happens only after a turn
846
+ # SUCCEEDS, via _save_session_state below.
787
847
  if message is not None:
788
848
  stdin_text = _stdin_context()
789
849
  if stdin_text:
790
850
  attachments.insert(0, Attachment(name="<stdin>", content=stdin_text.encode()))
791
- result = asyncio.run(_run_turn(transport, message, emitter, files=attachments))
851
+ try:
852
+ result = asyncio.run(_run_turn(transport, message, emitter, files=attachments))
853
+ except ClawAccessDeniedError:
854
+ _forget_inaccessible(cfg, target) # self-heal: drop it from `agents`
855
+ raise
792
856
  emitter.line("")
793
857
  if result.timed_out:
794
858
  emitter.line("⚠ 回合超时结束,回复可能不完整。")
@@ -832,6 +896,8 @@ def do_chat(
832
896
  except ArkclawError as e:
833
897
  # One bad turn (server failure, network blip) must not kill the
834
898
  # REPL — report it and keep the conversation open.
899
+ if isinstance(e, ClawAccessDeniedError):
900
+ _forget_inaccessible(cfg, target) # self-heal: drop it from `agents`
835
901
  emitter.line(f"\n✗ {e.code}: {e.message}")
836
902
  if e.hint:
837
903
  emitter.line(f" {e.hint}")
@@ -964,12 +1030,11 @@ def do_agents(emitter: Emitter) -> dict[str, Any]:
964
1030
  )
965
1031
  elif cfg.transport == "openclaw":
966
1032
  emitter.line(f"★ 当前登录(openclaw)· 空间 {cfg.space}")
967
- # The claws you can chat = the ones YOU have used from this machine,
968
- # remembered locally — NOT a server enumeration of the space. The
969
- # space-wide ListClawInstances returns every member's claws to anyone
970
- # (no per-user scoping reachable by the CLI's role), so we don't call it.
971
- # New claws enter via `arkclaw chat ci-...`; each chat is recorded
972
- # (record_session) and shows up here next time.
1033
+ # Candidates = the claws YOU have used from this machine (local history) +
1034
+ # your default — NOT a server enumeration (the space-wide list isn't
1035
+ # per-user scoped for the CLI's role). We then VERIFY access to each by
1036
+ # minting a throwaway ChatToken, so only claws you can actually chat are
1037
+ # shown; denied ones are pruned from history (and cleared as default).
973
1038
  seen: dict[str, float] = {}
974
1039
  for s in config_mod.list_sessions():
975
1040
  cid = s.get("claw")
@@ -978,12 +1043,29 @@ def do_agents(emitter: Emitter) -> dict[str, Any]:
978
1043
  seen[str(cid)] = max(seen.get(str(cid), 0.0), float(ts) if isinstance(ts, (int, float)) else 0.0)
979
1044
  if cfg.claw and str(cfg.claw) not in seen:
980
1045
  seen[str(cfg.claw)] = 0.0 # the default claw, even if not chatted yet
981
- claws = [
982
- {"ClawInstanceId": cid, "last_used": ts}
983
- for cid, ts in sorted(seen.items(), key=lambda kv: kv[1], reverse=True)
984
- ]
1046
+
1047
+ claw_ids = sorted(seen, key=lambda c: seen[c], reverse=True)
1048
+ checked = False
1049
+ if claw_ids:
1050
+ if not emitter.json:
1051
+ emitter.line(" 核对访问权限中…")
1052
+ try:
1053
+ _, id_token = _load_session(cfg)
1054
+ accessible, denied = _probe_claw_access(cfg, id_token, claw_ids)
1055
+ checked = True
1056
+ for cid in denied:
1057
+ config_mod.forget_claw(str(cfg.space), cid) # stop listing it
1058
+ if cfg.claw == cid:
1059
+ config_mod.save_config(dataclasses.replace(cfg, claw=None))
1060
+ cfg = config_mod.load_config() or cfg
1061
+ current["default_claw"] = cfg.claw
1062
+ claw_ids = [c for c in claw_ids if c in accessible]
1063
+ except ArkclawError:
1064
+ checked = False # offline / expired / no creds → show unchecked
1065
+
1066
+ claws = [{"ClawInstanceId": cid, "last_used": seen[cid]} for cid in claw_ids]
985
1067
  current["claws"] = claws
986
- current["source"] = "local-history"
1068
+ current["source"] = "verified" if checked else "local-history-unchecked"
987
1069
  if claws:
988
1070
  emitter.table(
989
1071
  ["Claw(可 chat)", "上次使用", "默认"],
@@ -996,6 +1078,10 @@ def do_agents(emitter: Emitter) -> dict[str, Any]:
996
1078
  for c in claws
997
1079
  ],
998
1080
  )
1081
+ if not checked:
1082
+ emitter.line(" ⚠ 未能核对权限(离线/会话过期),以上为本地历史,可能含你无权访问的。")
1083
+ elif checked:
1084
+ emitter.line(" 你在本空间没有可访问的 claw(历史里的都已无权或不存在)。")
999
1085
  else:
1000
1086
  emitter.line(" 还没用过任何 claw。用 `arkclaw chat ci-xxxx` 开始,之后这里会记住它。")
1001
1087
 
@@ -118,6 +118,20 @@ def discover_cli_config(url: str) -> dict[str, object] | None:
118
118
  if result.get(k):
119
119
  out["client_id"] = result[k]
120
120
  break
121
+ # The remaining CLI-plane fields (platform-added). Once the BFF surfaces
122
+ # these on GetAuthConfig, the user types ONLY the address — login/init
123
+ # resolve everything. (camelCase from the BFF → snake_case the CLI uses.)
124
+ for src, dst in (
125
+ ("roleTrn", "role_trn"),
126
+ ("providerTrn", "provider_trn"),
127
+ ("region", "region"),
128
+ ("spaceId", "space_id"),
129
+ ("transport", "transport"),
130
+ ("endpoint", "endpoint"),
131
+ ("defaultClawId", "clawid"),
132
+ ):
133
+ if result.get(src):
134
+ out[dst] = result[src]
121
135
  return out or None
122
136
 
123
137
 
@@ -16,7 +16,7 @@ import urllib.error
16
16
  import urllib.parse
17
17
  import urllib.request
18
18
 
19
- from ee_claw.errors import NetworkError, StsError, redact
19
+ from ee_claw.errors import ClawAccessDeniedError, NetworkError, StsError, redact
20
20
 
21
21
  STS_HOST = "sts.volcengineapi.com"
22
22
  STS_VERSION = "2018-01-01"
@@ -164,7 +164,7 @@ def get_chat_token(
164
164
  err_body = e.read().decode(errors="replace")
165
165
  code, message = _volc_error(err_body)
166
166
  if code == "AccessDenied" or "AccessDenied" in err_body: # coruscant per-user gate
167
- raise StsError(
167
+ raise ClawAccessDeniedError(
168
168
  "权限不足:当前用户无权访问该 Claw 实例(非所有者且未获授权)。",
169
169
  hint="运行 `arkclaw agents` 查看你有权访问的 Claw 实例,或使用 `--clawid` 指定其它实例。",
170
170
  ) from e
@@ -1,6 +1,10 @@
1
- """OpenClaw WebSocket transport (protocol v3).
1
+ """OpenClaw WebSocket transport (protocol v3–v4, negotiated).
2
2
 
3
3
  Refactored from the live-validated ``core.ws_chat`` (2026-06-05, real EE claw).
4
+ The connect frame negotiates ``minProtocol=3 .. maxProtocol=4`` so it works
5
+ against both older claws (v3) and OpenClaw ≥5.28 (v4); the server picks the
6
+ version. (The post-connect frame shapes below were calibrated on a live v3
7
+ claw; v4 is expected to be compatible for these — verify against a v4 claw.)
4
8
  Confirmed protocol details, preserved exactly:
5
9
 
6
10
  * The ChatToken from ``GetClawInstanceChatToken`` is **single-use** — a fresh
@@ -9,7 +13,7 @@ Confirmed protocol details, preserved exactly:
9
13
  ``connect.challenge`` event (no response required), then expects a
10
14
  ``connect`` request whose ``client.id`` MUST be ``openclaw-control-ui``
11
15
  (server whitelist; anything else → INVALID_REQUEST), with
12
- ``role=operator`` / ``scopes=["operator.admin"]`` / protocol pinned to 3.
16
+ ``role=operator`` / ``scopes=["operator.admin"]`` / protocol negotiated v3–v4.
13
17
  * ``chat.send`` (sessionKey ``agent:main:main``) → ``{ok:true, payload:{runId,
14
18
  status:"started"}}``, then ``agent`` events stream assistant text
15
19
  (``data.delta`` incremental) and the ``chat`` event with ``state=final``
@@ -31,6 +35,8 @@ from __future__ import annotations
31
35
 
32
36
  import asyncio
33
37
  import json
38
+ import os
39
+ import sys
34
40
  import urllib.parse
35
41
  import uuid
36
42
  from collections.abc import Sequence
@@ -43,6 +49,11 @@ from ee_claw.transport.base import Approver, Attachment, OnEvent, TurnEvent, Tur
43
49
 
44
50
  SESSION_KEY = "agent:main:main" # OpenClaw default session
45
51
  WS_CLIENT_ID = "openclaw-control-ui" # ONLY this client.id is accepted
52
+ # ws protocol versions this client speaks; the server negotiates the highest
53
+ # mutually-supported one (v3 for older claws, v4 for OpenClaw ≥ 5.28). Pinning a
54
+ # single version makes a newer claw reject the connect with PROTOCOL_MISMATCH.
55
+ MIN_PROTOCOL = 3
56
+ MAX_PROTOCOL = 4
46
57
 
47
58
 
48
59
  def session_key_for(name: str | None) -> str:
@@ -61,14 +72,40 @@ class _Ws(Protocol):
61
72
  async def recv(self) -> str | bytes: ...
62
73
 
63
74
 
75
+ class _DebugWs:
76
+ """Frame tracer. With ``ARKCLAW_DEBUG_WS`` set, dump every ws frame (sent
77
+ and received) to stderr — redacted (no tokens) and length-capped — for
78
+ diagnosing protocol changes (e.g. OpenClaw v4) against a live claw."""
79
+
80
+ def __init__(self, ws: Any) -> None:
81
+ self._ws = ws
82
+
83
+ async def send(self, data: str) -> None:
84
+ sys.stderr.write(f"[ws →] {redact(data)[:2000]}\n")
85
+ sys.stderr.flush()
86
+ await self._ws.send(data)
87
+
88
+ async def recv(self) -> str | bytes:
89
+ data = await self._ws.recv()
90
+ text = data if isinstance(data, str) else bytes(data).decode("utf-8", "replace")
91
+ sys.stderr.write(f"[ws ←] {redact(text)[:2000]}\n")
92
+ sys.stderr.flush()
93
+ return data
94
+
95
+
96
+ def _wrap_debug(ws: Any) -> Any:
97
+ """Wrap ``ws`` in a frame tracer when ARKCLAW_DEBUG_WS is set; else pass through."""
98
+ return _DebugWs(ws) if os.environ.get("ARKCLAW_DEBUG_WS") else ws
99
+
100
+
64
101
  def connect_frame() -> dict[str, Any]:
65
102
  return {
66
103
  "type": "req",
67
104
  "id": str(uuid.uuid4()),
68
105
  "method": "connect",
69
106
  "params": {
70
- "minProtocol": 3,
71
- "maxProtocol": 3,
107
+ "minProtocol": MIN_PROTOCOL,
108
+ "maxProtocol": MAX_PROTOCOL,
72
109
  "client": {
73
110
  "id": WS_CLIENT_ID,
74
111
  "version": "arkclaw-cli",
@@ -369,6 +406,7 @@ class OpenClawTransport:
369
406
  connect = websockets.connect
370
407
  try:
371
408
  async with connect(url, max_size=None) as ws:
409
+ ws = _wrap_debug(ws)
372
410
  self._active_ws = ws
373
411
  try:
374
412
  return await drive_chat(
@@ -428,6 +466,7 @@ class OpenClawTransport:
428
466
  connect = websockets.connect
429
467
  try:
430
468
  async with connect(url, max_size=None) as ws:
469
+ ws = _wrap_debug(ws)
431
470
  await ws.send(json.dumps(connect_frame()))
432
471
  loop = asyncio.get_running_loop()
433
472
  deadline = loop.time() + self.turn_timeout